Skip to content

Expansion can emit both type and @type on one node #292

Description

@thehabes

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions