Observation-class finding, filed per Prime Directive #10 while implementing #7223 (PR #7371). Unassigned, deliberately not queued.
This is a divergence report, not a defect claim. #7223's suite pins each implementation's behaviour as measured; nothing in this repo currently RULES which of the two answers is correct in any of the three rows below. That ruling is what this card is for. ⛔ Do not "fix" one side without making the ruling — two of the three rows have a plausible case for the facade's answer.
How this was measured
METADATA_ROUNDTRIP_CASES (@objectstack/spec/contracts, added in PR #7371) replayed against every shipped implementation by packages/objectql/src/metadata-service-roundtrip-conformance.test.ts. Subjects: MetadataManager (registry only), MetadataManager (writable datasource: loader), createMemoryMetadata, MetadataFacade, plus the contract's own reference double in packages/spec. The first four columns agree on every row in the table; MetadataFacade is alone on these three.
The cells
1. The effective key is data.name, not the name argument
Cases key-is-the-name-argument-object, key-is-the-name-argument-nonobject.
await service.register('object', 'pin_key', { name: 'pin_data_name', … });
await service.get('object', 'pin_key'); // manager/memory/reference: the document
// MetadataFacade: undefined
await service.exists('object', 'pin_key'); // …: true / facade: false
await service.listNames('object'); // …: ['pin_key'] / facade: ['pin_data_name']
MetadataFacade.register builds { ...data, name: data.name ?? name } and hands the DOCUMENT to SchemaRegistry.registerObject / registerItem, which key on the document's own name. The name argument is therefore only a fallback for a document that carries none. Measured identically on an object-typed and a view-typed write, so it is not object special-casing.
This is the round-trip #7223 is about: register(t, n, d) → get(t, n) does not hold on MetadataFacade whenever d.name !== n.
The contract TSDoc names the parameter on both members (@param name - Item name/identifier (snake_case)) and says nothing about data.name. Neither answer is written down.
Worth noting before ruling: an implementation whose store is keyed by the document's own identity is not obviously wrong — it is arguably the reason SchemaRegistry behaves this way. The candidate rulings are (a) the argument wins, (b) the document wins, (c) a disagreement is refused loudly rather than silently resolved either way. (c) is the only one that cannot silently misplace an item, and it is a behaviour change on all four implementations, not just the facade.
2. The plural objects type is aliased to object
Case plural-objects-type-is-its-own-store.
await service.register('objects', 'pin_plural', objectDoc);
await service.get('object', 'pin_plural'); // manager/memory/reference: undefined
// MetadataFacade: the document
MetadataFacade.isObjectType treats 'object' and 'objects' as one type — deliberately, per its own header (#6725 left the plural with the same read/write split the singular had). The consequence measured here is on the READ side. MetadataManager and createMemoryMetadata key their type stores on the string they are handed, so the two spellings are two stores.
This one has a live cross-reference: check:meta-type-normalized exists as a gate, so the repo already has an opinion somewhere about type-name normalization. Whoever rules on this should reconcile the two rather than treat this as a fresh question.
3. A non-object data value is silently dropped
Case primitive-data-roundtrips.
await service.register('setting', 'pin_flag', 'enabled'); // ACCEPTED, no throw
await service.get('setting', 'pin_flag'); // manager/memory/reference: 'enabled'
// MetadataFacade: undefined
await service.exists('setting', 'pin_flag'); // …: true / facade: false
await service.listNames('setting'); // …: ['pin_flag'] / facade: []
The contract declares data: unknown, not object. MetadataFacade.register passes a non-object value through unchanged (its spread branch is guarded on typeof data === 'object' && data !== null) and then registers it under the document's own name — which a string does not have. The registry logs Registered setting: undefined and the value is readable back through no member.
This is the one row where the facade's answer looks hard to defend as it stands: the write is accepted and then lost, with no error and no way for the caller to notice. That is the same silent-loss family as #6725. Note the fix is not necessarily "store it" — refusing a data the implementation cannot key is equally consistent with the contract. What is not defensible is accepting and dropping.
Where the pins live
packages/objectql/src/metadata-service-roundtrip-conformance.test.ts, under // DIVERGENCE markers 1–3, each with the mechanism written out. A behaviour change to any of these rows turns that file red — which is the intended prompt to update the pin in the PR that makes the ruling, not silently.
Refs #7223, PR #7371, #6725, PR #7211, #6745.
Observation-class finding, filed per Prime Directive #10 while implementing #7223 (PR #7371). Unassigned, deliberately not queued.
This is a divergence report, not a defect claim. #7223's suite pins each implementation's behaviour as measured; nothing in this repo currently RULES which of the two answers is correct in any of the three rows below. That ruling is what this card is for. ⛔ Do not "fix" one side without making the ruling — two of the three rows have a plausible case for the facade's answer.
How this was measured
METADATA_ROUNDTRIP_CASES(@objectstack/spec/contracts, added in PR #7371) replayed against every shipped implementation bypackages/objectql/src/metadata-service-roundtrip-conformance.test.ts. Subjects:MetadataManager(registry only),MetadataManager(writabledatasource:loader),createMemoryMetadata,MetadataFacade, plus the contract's own reference double inpackages/spec. The first four columns agree on every row in the table;MetadataFacadeis alone on these three.The cells
1. The effective key is
data.name, not thenameargumentCases
key-is-the-name-argument-object,key-is-the-name-argument-nonobject.MetadataFacade.registerbuilds{ ...data, name: data.name ?? name }and hands the DOCUMENT toSchemaRegistry.registerObject/registerItem, which key on the document's ownname. Thenameargument is therefore only a fallback for a document that carries none. Measured identically on an object-typed and a view-typed write, so it is not object special-casing.This is the round-trip #7223 is about:
register(t, n, d)→get(t, n)does not hold onMetadataFacadewheneverd.name !== n.The contract TSDoc names the parameter on both members (
@param name - Item name/identifier (snake_case)) and says nothing aboutdata.name. Neither answer is written down.Worth noting before ruling: an implementation whose store is keyed by the document's own identity is not obviously wrong — it is arguably the reason
SchemaRegistrybehaves this way. The candidate rulings are (a) the argument wins, (b) the document wins, (c) a disagreement is refused loudly rather than silently resolved either way. (c) is the only one that cannot silently misplace an item, and it is a behaviour change on all four implementations, not just the facade.2. The plural
objectstype is aliased toobjectCase
plural-objects-type-is-its-own-store.MetadataFacade.isObjectTypetreats'object'and'objects'as one type — deliberately, per its own header (#6725 left the plural with the same read/write split the singular had). The consequence measured here is on the READ side.MetadataManagerandcreateMemoryMetadatakey their type stores on the string they are handed, so the two spellings are two stores.This one has a live cross-reference:
check:meta-type-normalizedexists as a gate, so the repo already has an opinion somewhere about type-name normalization. Whoever rules on this should reconcile the two rather than treat this as a fresh question.3. A non-object
datavalue is silently droppedCase
primitive-data-roundtrips.The contract declares
data: unknown, notobject.MetadataFacade.registerpasses a non-object value through unchanged (its spread branch is guarded ontypeof data === 'object' && data !== null) and then registers it under the document's ownname— which a string does not have. The registry logsRegistered setting: undefinedand the value is readable back through no member.This is the one row where the facade's answer looks hard to defend as it stands: the write is accepted and then lost, with no error and no way for the caller to notice. That is the same silent-loss family as #6725. Note the fix is not necessarily "store it" — refusing a
datathe implementation cannot key is equally consistent with the contract. What is not defensible is accepting and dropping.Where the pins live
packages/objectql/src/metadata-service-roundtrip-conformance.test.ts, under// DIVERGENCEmarkers 1–3, each with the mechanism written out. A behaviour change to any of these rows turns that file red — which is the intended prompt to update the pin in the PR that makes the ruling, not silently.Refs #7223, PR #7371, #6725, PR #7211, #6745.