Repository navigation
Description
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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Activity
thisfrom generatedclone()anddeepClone()#1354withRawProperty()andwithRawProperties()#1355dahlia commentedon Nov 1, 2026
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
RawPropertyValueis a placeholder for the expanded JSON-LD value type; it has to cover value objects, node references, node objects,@listobjects, and@graphobjects.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(), andgetExpandedPropertyValues()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@listproperty 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
redundantPropertiesaliases. They are captured in the outermostfromJsonLd()frame, independently of_cachedJsonLdand 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 byclone()anddeepClone(), anddeepClone()'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, whoseredundantPropertiesWrite: "canonical"means the alias keys are never emitted.The alternatives were:
TypeError. When Fedify later adds typed support for an extension, every application reading it through this function would break on upgrade.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 asgetObject()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
awaitstays, though for a different reason than lazy re-expansion. Retained values could be returned synchronously, but encoding known values is asynchronous (jsonld.expand(), nestedtoJsonLd(), 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 throwsTypeError. Empty strings,@-prefixed keywords, blank node identifiers, bare terms such ascontent, and relative references are rejected withTypeError. Strings are checked against RFC 3987 syntax without trimming or repair.A compact IRI such as
as:contentis 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 thanhttp:orhttps: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 asas:ortoot:. Warnings are deduplicated per IRI through a bounded FIFO set, so an inbox handler cannot flood the log. Restricting the API tohttp:andhttps: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. AURLargument is read once before anyawait.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 throwsTypeErroron a true cycle. An object shared by two branches is not a cycle, andURLreferences 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) fromstaticblocks into a module-private registry, following the existing deep-clone descriptors. A versionedSymbol.for()protocol lets one module's function read another module's objects; anything without the protocol is rejected withTypeError.The brand is the string key
"~fedify.vocab", a required property backed by a non-enumerable prototype getter, rather than aunique symbol.unique symboltypes are distinct per declaration, so two copies of@fedify/vocab-runtimein one dependency graph, which third-party vocabulary packages make likely, would make objects from one module fail the other module'sVocabularyObjecttype.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 passoptions.baseUrlas the base, respecting@base: null, without post-resolving retained strings. This changes existing decoding, so it lands first with its own compatibility tests.@fedify/lintgets araw-property-iri-invalidrule (warn) that checks statically known IRI arguments: string literals, expression-free template literals,new URL("…")with the globalURL, andconstbindings. By default it inspects functions imported from@fedify/vocab; ESLint and Oxlint (1.31.0 or later) accept amodulesoption 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
withRawProperty()andwithRawProperties(), is tracked in AddwithRawProperty()andwithRawProperties()#1355 under Make it easier to extend Activity Vocabulary API with custom activities/objects #207.clone()anddeepClone()toVocabularyObjectrequires changing their generated return types tothis, which breaks subclass overrides. That is tracked for Fedify 3.0 in Returnthisfrom generatedclone()anddeepClone()#1354.Add VocabularyObject and raw property helpers
Generate raw property readers in vocab modules
Add the raw-property-iri-invalid lint rule
Reference the issue in embedded context entries