From 27beb651d830e36051ea16676b74ea34e5837e80 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 26 Aug 2026 12:11:23 -0500
Subject: [PATCH 1/4] Do not let Annotations assert 'type' or '@type' onto
entities.
---
controllers/utils.js | 8 +++++++-
routes/__tests__/id.test.js | 6 ++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/controllers/utils.js b/controllers/utils.js
index a5c09bc5..fc8b804f 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -123,8 +123,14 @@ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "targ
/**
* Identity, system, and processing properties an Annotation body must never overwrite when its
* assertions are merged onto an entity.
+ *
+ * 'type' and '@type' are here because class is identity. The record declares what it is; an
+ * Annotation targeting it describes it and has no standing to say it is also something else.
+ * Without them a body carrying a 'type' appended to the entity's own class, so the expanded
+ * entity answered to a type the stored record never claimed and clients had to re-read the
+ * unexpanded record to recover the real one.
*/
-const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context"])
+const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"])
/**
* Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only
diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js
index 5d9f9773..8734c9fb 100644
--- a/routes/__tests__/id.test.js
+++ b/routes/__tests__/id.test.js
@@ -277,6 +277,10 @@ describe('GET /id/:id/expanded', () => {
anno({ body: { __rerum: { evil: true } } }),
anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }),
anno({ body: { "@context": "https://evil.example.org/context.json" } }),
+ // Class is identity. An Annotation describes the record; it does not get to say the record
+ // is also something else, under either spelling of the key.
+ anno({ body: { "@type": "Hijacked" } }),
+ anno({ body: { type: "Hijacked" } }),
// An object literal with a __proto__ key sets the prototype instead of creating an own
// property. JSON.parse creates the own property, which is what a MongoDB document has.
anno({ body: JSON.parse('{"__proto__":{"polluted":"yes"}}') })
@@ -291,6 +295,8 @@ describe('GET /id/:id/expanded', () => {
assert.strictEqual(response.body.__rerum.generatedBy, MOCK_AGENT)
assert.strictEqual(response.body.__deleted, undefined)
assert.strictEqual(response.body["@context"], "http://www.loc.gov/mods")
+ assert.strictEqual(response.body["@type"], "named-gloss", 'the record keeps the class it declared')
+ assert.strictEqual(response.body.type, undefined, 'an Annotation cannot add the other spelling either')
assert.strictEqual(Object.hasOwn(response.body, '__proto__'), false)
assert.strictEqual({}.polluted, undefined, 'Object.prototype must not be polluted')
assert.strictEqual(response.headers['annotations-merged'], '0')
From 8a5f8b2a275a6e13c5300d44f3752b9aae2b4279 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 26 Aug 2026 12:16:20 -0500
Subject: [PATCH 2/4] comment cleanup
---
controllers/utils.js | 6 ------
1 file changed, 6 deletions(-)
diff --git a/controllers/utils.js b/controllers/utils.js
index fc8b804f..cd465c47 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -123,12 +123,6 @@ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "targ
/**
* Identity, system, and processing properties an Annotation body must never overwrite when its
* assertions are merged onto an entity.
- *
- * 'type' and '@type' are here because class is identity. The record declares what it is; an
- * Annotation targeting it describes it and has no standing to say it is also something else.
- * Without them a body carrying a 'type' appended to the entity's own class, so the expanded
- * entity answered to a type the stored record never claimed and clients had to re-read the
- * unexpanded record to recover the real one.
*/
const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"])
From 05c29bff6c2b6cb33d1a6b3df2d529dc57d816ed Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 26 Aug 2026 12:17:30 -0500
Subject: [PATCH 3/4] comment cleanup
---
routes/__tests__/id.test.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js
index 8734c9fb..60e72756 100644
--- a/routes/__tests__/id.test.js
+++ b/routes/__tests__/id.test.js
@@ -277,8 +277,6 @@ describe('GET /id/:id/expanded', () => {
anno({ body: { __rerum: { evil: true } } }),
anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }),
anno({ body: { "@context": "https://evil.example.org/context.json" } }),
- // Class is identity. An Annotation describes the record; it does not get to say the record
- // is also something else, under either spelling of the key.
anno({ body: { "@type": "Hijacked" } }),
anno({ body: { type: "Hijacked" } }),
// An object literal with a __proto__ key sets the prototype instead of creating an own
From e63fb5dfff3cbd4c04f076e5124e7282ae529e44 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 26 Aug 2026 13:56:23 -0500
Subject: [PATCH 4/4] update docs
---
controllers/utils.js | 5 +++--
public/API.html | 26 ++++++++++++--------------
2 files changed, 15 insertions(+), 16 deletions(-)
diff --git a/controllers/utils.js b/controllers/utils.js
index cd465c47..e7be35db 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -121,8 +121,9 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"])
const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]
/**
- * Identity, system, and processing properties an Annotation body must never overwrite when its
- * assertions are merged onto an entity.
+ * Identity, classification, system, and processing properties an Annotation body must never overwrite when its
+ * assertions are merged onto an entity. The entity stays the authority on its own '@context' and
+ * on the class it declares under 'type' or '@type'.
*/
const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"])
diff --git a/public/API.html b/public/API.html
index 3cd1a6a6..54804b09 100644
--- a/public/API.html
+++ b/public/API.html
@@ -1714,23 +1714,21 @@ Entity Expansion
record said and what each Annotation added.
- A record's identity, system, and processing properties are never overwritten by an Annotation. An
- Annotation body asserting @id,
+ A record's identity, classification, system, and processing properties
+ are never overwritten by an Annotation. An Annotation body asserting
+ @id,
id,
_id,
+ @context,
+ @type,
+ type,
__rerum,
- __deleted,
- __proto__, or
- @context contributes nothing. The expanded record
- answers to the same URI as the record you asked for, and reads under the same
- @context.
-
-
- Linked Data keywords that describe the record rather than identify it are not held back. An Annotation
- asserting type or
- @type
- contributes it like any other property, which collects the record's own value and the asserted one
- into an Array.
+ __deleted, or
+ __proto__
+ contributes nothing. The expanded record answers to the same URI as the
+ record you asked for, and reads under the same
+ @context
+ as the same class of thing it declared itself to be.
A deleted record is never expanded. Whatever it used to assert is inside its