Split out of the static review of #286 so it does not block that PR.
What happens now
applyExpansionAnnotations() in controllers/crud.js merges an Annotation body's key onto the entity as an ordinary property. type and @type are deliberately let through — public/API.html says so under Entity Expansion:
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.
The intent is reasonable. The problem is that type and @type are not two properties. In every @context RERUM serves an expansion under, type is an alias for @type:
http://iiif.io/api/presentation/3/context.json defines "type": "@type"
http://www.w3.org/ns/anno.jsonld defines "type": "@type"
The merge treats them as independent keys, so a record carrying one spelling plus an Annotation asserting the other produces a node object holding both.
Why it matters
Two properties expanding to the same keyword is a colliding keywords error in the JSON-LD 1.1 API. The response is served as application/ld+json;charset=utf-8;profile="http://www.w3.org/ns/anno.jsonld", so a conforming processor will refuse to expand a document /expanded just emitted.
A second-order consequence of the same code path, worth deciding on at the same time: IIIF Presentation 3 requires type to be a single string. An Annotation asserting type turns a Manifest's "type": "Manifest" into "type": ["Manifest", "Person"], which fails IIIF validation even when there is no collision.
Reproduction
Record:
{"@context": "http://iiif.io/api/presentation/3/context.json", "@id": "<uri>", "type": "Manifest"}
Two leaf Annotations targeting it:
{"type": "Annotation", "target": "<uri>", "body": {"type": "Person"}}
{"type": "Annotation", "target": "<uri>", "body": {"@type": "sc:Manuscript"}}
GET /v1/id/<id>/expanded responds 200 with Annotations-Merged: 2 and a body carrying both keys:
"type": ["Manifest", "Person"],
"@type": "sc:Manuscript"
Pasting that into the JSON-LD Playground and expanding raises colliding keywords.
Current impact: none
Measured against production (annotationStore.alpha, 336,187 documents) at the time of the #286 review:
- 78,023 leaf Annotations target a
rerum.io/v1/id/ URI under any of the six keys in TARGET_KEYS.
- 0 of them carry a body that is a single-key object keyed
type or @type.
Nothing reachable through the endpoint triggers this today. It is a gap that surfaces the first time an app writes an Annotation whose body asserts a class.
Possible approaches
Either closes the collision. The choice is about whether an Annotation should be able to say anything about an entity's class at all.
1. Fold the asserted keyword onto the spelling the record already uses. Keeps the documented behavior, no doc change needed:
/**
* A node names its class under one spelling. In JSON-LD 'type' is an alias for '@type' in every
* context RERUM serves, so a document holding both is a colliding-keywords error.
*/
const TYPE_KEYWORDS = new Set(["type", "@type"])
function applyExpansionAnnotations(primitiveEntity, annoAssertions) {
const expandedEntity = structuredClone(primitiveEntity)
const rerumProp = expandedEntity.__rerum
delete expandedEntity.__rerum
// The spelling the record already uses. An asserted type folds onto it rather than beside it.
const recordTypeKey = Object.hasOwn(expandedEntity, "@type") ? "@type"
: Object.hasOwn(expandedEntity, "type") ? "type"
: null
for (const assertions of annoAssertions) {
for (const [assertedKey, value] of assertions) {
if (PROTECTED_EXPANSION_KEYS.has(assertedKey)) continue
const key = (recordTypeKey && TYPE_KEYWORDS.has(assertedKey)) ? recordTypeKey : assertedKey
...
This still leaves the IIIF single-string question open, since folding produces an Array under one key.
2. Add type and @type to PROTECTED_EXPANSION_KEYS. Simpler, and it settles the IIIF case too, but it needs the Entity Expansion section of public/API.html amended to match.
Note that controllers/gog.js expand() shares PROTECTED_EXPANSION_KEYS, so approach 2 covers both endpoints in one line while approach 1 needs mirroring there.
Worth deciding at the same time whether Annotations-Merged should still count an Annotation whose only assertion was folded away.
Reference
Split out of the static review of #286 so it does not block that PR.
What happens now
applyExpansionAnnotations()incontrollers/crud.jsmerges an Annotation body's key onto the entity as an ordinary property.typeand@typeare deliberately let through —public/API.htmlsays so under Entity Expansion:The intent is reasonable. The problem is that
typeand@typeare not two properties. In every@contextRERUM serves an expansion under,typeis an alias for@type:http://iiif.io/api/presentation/3/context.jsondefines"type": "@type"http://www.w3.org/ns/anno.jsonlddefines"type": "@type"The merge treats them as independent keys, so a record carrying one spelling plus an Annotation asserting the other produces a node object holding both.
Why it matters
Two properties expanding to the same keyword is a
colliding keywordserror in the JSON-LD 1.1 API. The response is served asapplication/ld+json;charset=utf-8;profile="http://www.w3.org/ns/anno.jsonld", so a conforming processor will refuse to expand a document/expandedjust emitted.A second-order consequence of the same code path, worth deciding on at the same time: IIIF Presentation 3 requires
typeto be a single string. An Annotation assertingtypeturns a Manifest's"type": "Manifest"into"type": ["Manifest", "Person"], which fails IIIF validation even when there is no collision.Reproduction
Record:
{"@context": "http://iiif.io/api/presentation/3/context.json", "@id": "<uri>", "type": "Manifest"}Two leaf Annotations targeting it:
{"type": "Annotation", "target": "<uri>", "body": {"type": "Person"}} {"type": "Annotation", "target": "<uri>", "body": {"@type": "sc:Manuscript"}}GET /v1/id/<id>/expandedresponds200withAnnotations-Merged: 2and a body carrying both keys:Pasting that into the JSON-LD Playground and expanding raises
colliding keywords.Current impact: none
Measured against production (
annotationStore.alpha, 336,187 documents) at the time of the #286 review:rerum.io/v1/id/URI under any of the six keys inTARGET_KEYS.typeor@type.Nothing reachable through the endpoint triggers this today. It is a gap that surfaces the first time an app writes an Annotation whose body asserts a class.
Possible approaches
Either closes the collision. The choice is about whether an Annotation should be able to say anything about an entity's class at all.
1. Fold the asserted keyword onto the spelling the record already uses. Keeps the documented behavior, no doc change needed:
This still leaves the IIIF single-string question open, since folding produces an Array under one key.
2. Add
typeand@typetoPROTECTED_EXPANSION_KEYS. Simpler, and it settles the IIIF case too, but it needs the Entity Expansion section ofpublic/API.htmlamended to match.Note that
controllers/gog.jsexpand()sharesPROTECTED_EXPANSION_KEYS, so approach 2 covers both endpoints in one line while approach 1 needs mirroring there.Worth deciding at the same time whether
Annotations-Mergedshould still count an Annotation whose only assertion was folded away.Reference
colliding keywords: https://www.w3.org/TR/json-ld11-api/#dom-jsonlderrorcode-colliding-keywordstype: https://iiif.io/api/presentation/3.0/#type