Skip to content

Allow access to unsupported vocabulary properties #1272

Description

@dahlia

Proposed sub-issue of #207: provide access to extension properties on vocabulary objects without requiring typed bindings for each extension.

Motivation

Applications need to read properties that Fedify does not yet support. Requiring an upstream vocabulary addition for every application-specific property makes it harder to try new extensions or build applications around a vocabulary used by only a few peers.

In fiddur/aurboda#1223, Aurboda delivers a followers-only post's data-access token in quant:structuredUrl, but its receiving code reads the token from image attachment URLs because the typed Note has no accessor for the extension property. Posts without images therefore cannot show native charts on the receiving instance. Reading the delivered property would remove the dependency on an unrelated attachment.

The JSON-LD serialization cache provides a partial workaround, but whether it retains an extension depends on context and serialization options. In a reproduction on current main, a standalone Note.fromJsonLd() retained the extension in its default toJsonLd() output. A Note embedded in a Create, using only the parent's context, lost it after getObject(), while the parent's default output still retained it. Giving the embedded Note its own context changed that result. Explicit compact or expanded output and clone() also lost the standalone Note's extension. Applications should not need to account for these cache and context differences to read a property.

Proposal

Provide a public API to read extension values by their full property IRI. It should use JSON-LD expansion so that different prefixes or aliases for the same IRI produce the same lookup result.

A standalone function would avoid collisions with generated vocabulary accessors. The name and signature are open for discussion:

const values = await extensionValues(
  note,
  "https://w3id.org/quantpub#structuredUrl",
);

The function would return expanded JSON-LD values rather than infer application-specific types. Callers would validate and interpret those values themselves. A missing property would return an empty array. If this is implemented as an object method instead, its name should avoid the get prefix to prevent collisions with generated accessors.

The implementation should preserve extension data independently of the serialization cache. Reading values must work for embedded and fetched objects and remain possible after clone(). Returned data should not expose mutable internal state, and the lookup should not dereference IRI-valued properties automatically.

Tests should cover parent and local contexts, aliases for the same property IRI, multiple values, JSON literals, and access after cloning or serialization. Document the returned value shapes and include an example of reading an unsupported property.

Adding extension properties to locally constructed objects and preserving them in outgoing JSON-LD can be handled separately under #207.

Activity

  1. added theissue type on Oct 8, 2026
  2. added this to the Fedify 2.5 milestone on Oct 8, 2026
  3. self-assigned this
    on Nov 1, 2026
  4. dahlia commented on Nov 1, 2026

    @dahlia
    MemberAuthor

    I worked through the proposal above in several rounds and the design has changed in a few places. This comment records where it landed and why, including the options that were dropped, so the reasoning stays next to the issue.

    Resulting API

    // Generated by @fedify/vocab-tools into every vocabulary module,
    // including @fedify/vocab:
    export function getRawPropertyValues(
      object: VocabularyObject,
      propertyIri: string | URL,
    ): Promise<RawPropertyValue[]>;
    
    export function getRawPropertyValue(
      object: VocabularyObject,
      propertyIri: string | URL,
    ): Promise<RawPropertyValue | null>;
    
    // In @fedify/vocab-runtime:
    export interface VocabularyObject {
      readonly "~fedify.vocab": true;
      readonly id: URL | null;
      toJsonLd(options?: { /* … */ }): Promise<unknown>;
    }

    RawPropertyValue is a placeholder for the expanded JSON-LD value type; it has to cover value objects, node references, node objects, @list objects, and @graph objects.

    Name

    extensionValues() does not start with a verb, which reads oddly for a function, and “extension” stops being accurate once known IRIs are accepted (see below). getJsonLdValues(), getJsonLdPropertyValues(), and getExpandedPropertyValues() were considered. getRawPropertyValues() won: “raw” means untyped expanded JSON-LD as opposed to the values typed accessors return. It does not mean the bytes that were received, and the JSDoc and docs/manual/vocab.md have to say that up front.

    getRawPropertyValue() is exactly (await getRawPropertyValues(o, iri))[0] ?? null. It does not throw on multiple values, consistent with the generated singular accessors, and “first” carries no ordering guarantee. For an @list property it returns the whole { "@list": [...] } object.

    Retention and known IRIs

    Only unknown properties are retained: those the concrete class does not define after subtype dispatch, including inherited properties and redundantProperties aliases. They are captured in the outermost fromJsonLd() frame, independently of _cachedJsonLd and of whether caching was disabled because another property failed to decode. Embedded objects retain their own properties, since they are decoded from the parent's expanded node. The frozen map is shared by clone() and deepClone(), and deepClone()'s JSDoc, which currently says unknown properties are not preserved, gets updated. Results are deep copies.

    Retention and lookup are deliberately independent. The function also accepts known property IRIs: it re-encodes the current typed values with generated per-property encoders, which read the backing fields directly instead of going through the accessors, so a lookup never fetches anything or changes trust state. An alias IRI maps to its canonical field. This matters for properties like license, whose redundantPropertiesWrite: "canonical" means the alias keys are never emitted.

    The alternatives were:

    • Retaining the full expanded node, so known IRIs return the received values. It costs memory for every object in a busy inbox, which almost never carries extensions.
    • Rejecting known IRIs with TypeError. When Fedify later adds typed support for an extension, every application reading it through this function would break on upgrade.
    • Serializing the whole object with toJsonLd({ format: "expand" }) and picking the key. It does unrelated work, can fail because of an unrelated property, and returns [] for canonical-write aliases.

    Re-encoding is not a strict round trip, and the documentation will list the differences: items skipped by lenient decoding, lost @direction, normalized date-time and decimal lexical forms, relative IRIs made absolute, graph containers wrapped per item, canonical values winning over aliases, clone() overrides, and hydration. When an accessor such as getObject() has fetched and replaced an embedded object, a lookup reflects the fetched object. That side effect is part of the contract. Encoding a parent's known property does not include the child's retained unknown properties, and results are not proof-preserving documents.

    The headline rule for the docs: unknown IRIs return retained values, known IRIs return re-encoded current values, and neither dereferences IRIs or verifies anything.

    Asynchronous

    The proposal's await stays, though for a different reason than lazy re-expansion. Retained values could be returned synchronously, but encoding known values is asynchronous (jsonld.expand(), nested toJsonLd(), key export). A synchronous variant for unknown IRIs only would stop working for a property the day Fedify adds typed support for it.

    Input validation

    The argument is string | URL; anything else throws TypeError. Empty strings, @-prefixed keywords, blank node identifiers, bare terms such as content, and relative references are rejected with TypeError. Strings are checked against RFC 3987 syntax without trimming or repair.

    A compact IRI such as as:content is syntactically an absolute IRI, so a context-free function cannot reject it without also rejecting legitimate opaque schemes. It is accepted and looked up literally, and normally yields []. Prefixes are never expanded. Because a scheme other than http: or https: is almost always a mistake, the function logs a warning under ["fedify", "vocab"], with a hint when the scheme matches a well-known prefix such as as: or toot:. Warnings are deduplicated per IRI through a bounded FIFO set, so an inbox handler cannot flood the log. Restricting the API to http: and https: was considered and rejected for the same reason.

    Lookup tries the exact string first and falls back to the WHATWG-normalized href. The fallback runs only when the exact key is neither a known IRI nor a retained key, not merely when the result is empty. A URL argument is read once before any await.

    Cycles

    Re-encoding hydrated objects can meet a cycle. Each call tracks the objects currently on the encoding stack, propagated through nested encoding including nested toJsonLd(), and throws TypeError on a true cycle. An object shared by two branches is not a cycle, and URL references are leaves.

    Module boundaries

    The functions are generated by @fedify/vocab-tools, so any vocabulary module generated with it gets the same API. Validation, normalization, copying, warnings, and cycle tracking live once in @fedify/vocab-runtime. Generated classes register their per-class logic (known IRIs, alias map, encoders, access to retained data) from static blocks into a module-private registry, following the existing deep-clone descriptors. A versioned Symbol.for() protocol lets one module's function read another module's objects; anything without the protocol is rejected with TypeError.

    The brand is the string key "~fedify.vocab", a required property backed by a non-enumerable prototype getter, rather than a unique symbol. unique symbol types are distinct per declaration, so two copies of @fedify/vocab-runtime in one dependency graph, which third-party vocabulary packages make likely, would make objects from one module fail the other module's VocabularyObject type.

    Related changes

    The decode-side jsonld.expand() call does not pass a base today, so retained unknown values and typed values can resolve relative IRIs differently. fromJsonLd() will pass options.baseUrl as the base, respecting @base: null, without post-resolving retained strings. This changes existing decoding, so it lands first with its own compatibility tests.

    @fedify/lint gets a raw-property-iri-invalid rule (warn) that checks statically known IRI arguments: string literals, expression-free template literals, new URL("…") with the global URL, and const bindings. By default it inspects functions imported from @fedify/vocab; ESLint and Oxlint (1.31.0 or later) accept a modules option listing more generated modules. Deno Lint plugin rules cannot take options, so under Deno Lint the rule only covers @fedify/vocab, which the docs will state.

    Follow-ups

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Fields

Priority

None yet

Effort

None yet

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions