From 095ba28d7608da188e68d2d2d186ecffe1925776 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:56:21 +0000 Subject: [PATCH] fix(spec): state the #7378 three-cell register ruling in the IMetadataService contract, table and reference double (#8350) The contract TSDoc stated the superseded 2026-08-11 option-(a) ruling as current, including a 'PARKED - do not implement' paragraph pointed at behaviour the 2026-08-12 ruling adopted and PR #8349 shipped. Now: - register/get TSDoc states the ruled contract (mismatch refused loudly, non-object/array data refused, type keys on the canonical fold) - METADATA_ROUNDTRIP_CASES gains a 'refused' expectation kind; the five ruled rows carry the ruled answers and are renamed to state them - the reference double refuses rows 1/3 and folds row 2, restating the @objectstack/core guard locally (spec is the dependency root) - objectql driver: RULED_CONTRACT_ANSWERS + wiring tests deleted; all four subjects held to the table directly (the handoff working) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .../metadata-service-contract-ruled-table.md | 11 ++ ...data-service-roundtrip-conformance.test.ts | 115 +++------------- ...data-service-roundtrip-conformance.test.ts | 123 ++++++++++++++++-- .../metadata-service-roundtrip-conformance.ts | 98 ++++++++------ .../spec/src/contracts/metadata-service.ts | 80 +++++++----- 5 files changed, 248 insertions(+), 179 deletions(-) create mode 100644 .changeset/metadata-service-contract-ruled-table.md diff --git a/.changeset/metadata-service-contract-ruled-table.md b/.changeset/metadata-service-contract-ruled-table.md new file mode 100644 index 0000000000..d1ac6cf12f --- /dev/null +++ b/.changeset/metadata-service-contract-ruled-table.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +`IMetadataService` now states the #7378 three-cell register ruling (maintainer, 2026-08-12), superseding the 2026-08-11 option-(a) text the contract carried — including a "(c) PARKED — do not implement" paragraph pointed at behaviour that is now ruled and shipped. + +- `register`/`get` TSDoc states the ruled contract: a `data.name` disagreeing with the `name` argument is refused loudly (`VALIDATION_ERROR`, status 400, locating message naming both spellings, nothing stored); a non-object (or array) `data` is refused, never accepted-and-dropped or coerced into storability; type stores key on the canonical singular type (plural spellings fold through `PLURAL_TO_SINGULAR` before any store decision). +- `MetadataRoundTripExpectation` gains a `refused` kind, and the five ruled `METADATA_ROUNDTRIP_CASES` rows now carry the ruled answers. Four rows were renamed to state the ruled propositions: `key-is-the-name-argument-object`/`-nonobject` → `data-name-mismatch-refused-object`/`-nonobject`, `primitive-data-roundtrips` → `primitive-data-refused`, `array-data-roundtrips` → `array-data-refused`, and `plural-objects-type-is-its-own-store` → `plural-type-folds-to-canonical-store` (now `readable` through the canonical fold). +- The contract's reference double refuses rows 1/3 and folds row 2, restating the semantics of `assertMetadataRegisterContract` / `canonicalMetadataServiceType` (`@objectstack/core`) locally, since spec is the dependency root and cannot import core. + +The shipped implementations already behave this way (their half landed separately); this release makes the contract's declared text and executable table agree with them. diff --git a/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts index 0bed349062..52df7424d5 100644 --- a/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts +++ b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts @@ -25,35 +25,28 @@ * subject is the one that would notice a `register` that threw, silently * skipped, or mutated the document on the way to `loader.save`. * - * ## All three rows are now RULED (#7378) — and the table lags the ruling + * ## All three cells are RULED (#7378, 2026-08-12) — and the table carries them * * This file's `// DIVERGENCE` era is over. The maintainer's three-cell ruling - * of 2026-08-12 (#7378, 裁定人:维护者 huangyiirene), quoted verbatim and - * untranslated: - * - * > 1. **Row 1(key 归属)= (c) 响亮拒绝。** `register(type, name, data)` 中 - * > `name` 参数与 `data.name` 不一致时,所有实现统一拒绝并报错定位。 - * > 2. **Row 2(objects/object 别名)= 所有实现一个答案,与 - * > `check:meta-type-normalized` 收敛。** - * > 3. **Row 3(非对象 data 静默丢弃)= 响亮拒绝(throw)。** - * > - * > 三格的 `// DIVERGENCE` pin 在裁定 PR 内同步更新(该测试文件设计意图即如此)。 - * - * Every shipped implementation now enforces it through ONE shared guard — + * of 2026-08-12 (#7378, 裁定人:维护者 huangyiirene): mismatch between the + * `name` argument and `data.name` refused loudly (row 1); one answer for the + * objects/object spellings, converged with `check:meta-type-normalized` (row + * 2); non-object `data` refused, never accepted-and-dropped (row 3). Every + * shipped implementation enforces it through ONE shared guard — * `assertMetadataRegisterContract` / `canonicalMetadataServiceType` * (`@objectstack/core/metadata-service-contract`), whose header carries the - * full ruling text and the row-2 convergence rationale (the direction is - * `check:meta-type-normalized`'s: normalize once at the entry, decide on the - * normalized value — the gate's header carries #3984/#5881/#6241). + * full verbatim ruling text and the row-2 convergence rationale (the + * direction is `check:meta-type-normalized`'s: normalize once at the entry, + * decide on the normalized value — the gate's header carries + * #3984/#5881/#6241). * - * **{@link RULED_CONTRACT_ANSWERS} below overrides the table's `expected` for - * the five ruled case rows.** The table's own reference answers still describe - * the PRE-ruling reference semantics, because the table — and the contract's - * reference double beside it — live under `packages/spec/src/**`, whose half - * of this ruling is the `domain:spec` seat's, tracked on #7378. When that half - * lands (table rows re-ruled, reference double refusing/folding), the - * `table lags the ruling` wiring test below goes red on purpose: delete the - * override for each row it names and hold every subject to the table again. + * The shared table states the same ruling as `expected` answers — `refused` + * rows carry the ADR-0112 envelope contract, the plural row is `readable` + * through the canonical fold — so this driver holds every subject to the + * table directly. (Between the ruling's implementation half and its spec-side + * half, a `RULED_CONTRACT_ANSWERS` override map here carried the ruled + * answers over a still-pre-ruling table; the handoff wiring test went red + * when the table landed, and both were deleted, as designed.) * * ## Two assertion strengths, declared per subject * @@ -172,37 +165,6 @@ class WritableFixtureLoader implements MetadataLoader { } } -/** - * The RULED contract answer for a case, where the 2026-08-12 ruling and the - * table's (spec-side, still pre-ruling) `expected` disagree — see the header - * for why the two can disagree at all and for when each entry here dies. - * - * - `refused` — `register` must reject the case's write with the ADR-0112 - * envelope (`code` AND `status`), a locating message, and NOTHING stored. - * - `readable` — the case's final write is readable back, even though the - * table still says `absent`. - */ -type RuledAnswer = - | { readonly kind: 'refused'; readonly note: string } - | { readonly kind: 'readable'; readonly note: string }; - -const RULED_1 = - 'Row 1 (#7378, 2026-08-12): a data.name disagreeing with the name argument is REFUSED loudly by every implementation — silent resolution in either direction can misplace the item. Replaces the option-(a) argument-wins ruling of 2026-08-11 that the table still describes.'; - -const RULED_2 = - "Row 2 (#7378, 2026-08-12): all implementations give ONE answer, converged with check:meta-type-normalized's enforced direction — plural folds to singular before any decision, so 'objects' and 'object' address one store. The table's `absent` still describes the pre-ruling reference semantics (raw-string type keys)."; - -const RULED_3 = - 'Row 3 (#7378, 2026-08-12): a non-object data is REFUSED (throw) by every implementation — accept-then-drop was indefensible, and coercing into storability (the interim { name, content } box) is equally forbidden. The table still expects the value readable back.'; - -const RULED_CONTRACT_ANSWERS: Readonly> = { - 'key-is-the-name-argument-object': { kind: 'refused', note: RULED_1 }, - 'key-is-the-name-argument-nonobject': { kind: 'refused', note: RULED_1 }, - 'primitive-data-roundtrips': { kind: 'refused', note: RULED_3 }, - 'array-data-roundtrips': { kind: 'refused', note: RULED_3 }, - 'plural-objects-type-is-its-own-store': { kind: 'readable', note: RULED_2 }, -}; - const IMPLEMENTATIONS: readonly ShippedImplementation[] = [ { label: 'MetadataManager (registry only)', @@ -303,9 +265,8 @@ describe.each(IMPLEMENTATIONS)( '%s', async (_id, testCase) => { const service = implementation.create(); - const ruled = RULED_CONTRACT_ANSWERS[testCase.id]; - if (ruled?.kind === 'refused') { + if (testCase.expected.kind === 'refused') { await assertRefused(service, testCase); return; } @@ -320,10 +281,7 @@ describe.each(IMPLEMENTATIONS)( const got = await service.get(testCase.read.type, testCase.read.name); const exists = await service.exists(testCase.read.type, testCase.read.name); const names = await service.listNames(testCase.read.type); - const expected = - ruled?.kind === 'readable' - ? { kind: 'readable' as const, document: lastWrittenDocument(testCase) } - : testCase.expected; + const expected = testCase.expected; if (expected.kind === 'readable') { // Anti-vacuity: `toMatchObject` against an absent document @@ -359,9 +317,8 @@ describe.each(IMPLEMENTATIONS)( ); /** - * [#7378 rows 1/2] Driver-local pins the table does not carry (the table is the - * spec seat's half — see the header). These keep the ruled behaviour from - * passing for a wrong, narrower reason. + * [#7378 rows 1/2] Driver-local pins the table deliberately does not carry. + * These keep the ruled behaviour from passing for a wrong, narrower reason. */ describe.each(IMPLEMENTATIONS)('#7378 ruled behaviour, beyond the table [$label]', (implementation) => { it('row 1 is a MISMATCH rule: a document with NO name of its own registers under the argument', async () => { @@ -423,33 +380,3 @@ describe.each(IMPLEMENTATIONS)('#7378 ruled behaviour, beyond the table [$label] expect(await service.listNames('objects')).toContain('pin_both_ways'); }); }); - -describe('round-trip conformance table wiring', () => { - const ids = new Set(METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id)); - - it('declares no ruled override for a case id the table does not contain', () => { - // A renamed case would otherwise turn its override into a dead entry, - // and every subject would quietly be held to the pre-ruling reference - // answer it is known to fail. - for (const id of Object.keys(RULED_CONTRACT_ANSWERS)) { - expect(ids, id).toContain(id); - } - }); - - it('the table still lags the 2026-08-12 ruling — this red is the handoff signal', () => { - // The overrides above exist ONLY because the table and the reference - // double live under `packages/spec/src/**`, the `domain:spec` seat's - // half of #7378. When that half lands, each assertion here goes red: - // delete the corresponding RULED_CONTRACT_ANSWERS entry and let every - // subject be held to the table's (then-ruled) answer directly. - for (const [id, ruled] of Object.entries(RULED_CONTRACT_ANSWERS)) { - const testCase = METADATA_ROUNDTRIP_CASES.find((candidate) => candidate.id === id); - if (!testCase) continue; // the wiring test above owns this failure - if (ruled.kind === 'refused') { - expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('readable'); - } else { - expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('absent'); - } - } - }); -}); diff --git a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts index e38396627d..e1e4b11c8f 100644 --- a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts +++ b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts @@ -4,7 +4,8 @@ * `METADATA_ROUNDTRIP_CASES` driver #1 — the contract's own reference double. * * This file runs the shared table (`metadata-service-roundtrip-conformance.ts`) - * against a `Map`-of-`Map`s keyed by `type` × the `name` ARGUMENT, which is + * against a `Map`-of-`Map`s keyed by the CANONICAL type × the `name` ARGUMENT, + * refusing what it cannot key (#7378, maintainer ruling 2026-08-12) — which is * what the table's `expected` column means. Its subject is deliberately NOT a * shipped implementation: `packages/spec` is the dependency root and the * contract has no runtime, so nothing here can import one. The shipped @@ -36,26 +37,71 @@ import { METADATA_ROUNDTRIP_CASES, type MetadataRoundTripCase, } from './metadata-service-roundtrip-conformance'; +import { StandardErrorCode } from '../api/errors.zod'; +import { pluralToSingular } from '../shared/metadata-collection.zod'; /** - * The reference store: `type` → `name` → `data`, and nothing else. Written out - * here rather than imported so that the reference semantics live in the file - * that asserts them — the same store `metadata-service.test.ts` builds inline, - * typed against the contract so a signature change reaches this file via `tsc`. + * The #7378 three-cell register contract (maintainer ruling 2026-08-12), + * restated locally. The shipped implementations share ONE guard — + * `assertMetadataRegisterContract` / `canonicalMetadataServiceType` + * (`@objectstack/core`, `packages/core/src/metadata-service-contract.ts`, + * whose header carries the verbatim ruling) — which this file deliberately + * does NOT import: `packages/spec` is the dependency root and core depends on + * spec, not the other way round. The semantics are restated against the same + * `PLURAL_TO_SINGULAR` map (which spec itself owns, so the fold has one + * source), keeping the reference double executable from the root; keep the + * two in step through the shared conformance table, which both drivers replay. + */ +function registerRefusal(message: string): Error & { code: string; status: number } { + const error = new Error(message) as Error & { code: string; status: number }; + error.code = StandardErrorCode.enum.VALIDATION_ERROR; + error.status = 400; + return error; +} + +/** Rows 1 and 3: refuse what the store cannot key, before any write. */ +function assertRegisterContract(type: string, name: string, data: unknown): void { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + const shape = data === null ? 'null' : Array.isArray(data) ? 'an array' : `a ${typeof data}`; + throw registerRefusal( + `IMetadataService.register('${type}', '${name}'): data is ${shape}, not a metadata document — ` + + `refused, never accepted-and-dropped or coerced into storability (#7378 row 3).`, + ); + } + const documentName = (data as { name?: unknown }).name; + if (documentName !== undefined && documentName !== name) { + throw registerRefusal( + `IMetadataService.register('${type}', '${name}'): data.name is '${String(documentName)}', which disagrees ` + + `with the name argument '${name}' — refused, since silent resolution in either direction can misplace the item (#7378 row 1).`, + ); + } +} + +/** + * The reference store: CANONICAL `type` → `name` → `data`, guarded by the + * refusals above, and nothing else. Written out here rather than imported so + * that the reference semantics live in the file that asserts them — typed + * against the contract so a signature change reaches this file via `tsc`. + * Every member folds its `type` through `pluralToSingular` (#7378 row 2), so + * the two spellings of a type address one store in both directions. */ function createReferenceService(): IMetadataService { const store = new Map>(); const typeStore = (type: string): Map => { - let map = store.get(type); + const canonical = pluralToSingular(type); + let map = store.get(canonical); if (!map) { map = new Map(); - store.set(type, map); + store.set(canonical, map); } return map; }; return { - register: async (type, name, data) => { typeStore(type).set(name, data); }, + register: async (type, name, data) => { + assertRegisterContract(type, name, data); + typeStore(type).set(name, data); + }, get: async (type, name) => typeStore(type).get(name), list: async (type) => Array.from(typeStore(type).values()), unregister: async (type, name) => { typeStore(type).delete(name); }, @@ -66,6 +112,62 @@ function createReferenceService(): IMetadataService { }; } +/** + * The `name` a case's written document carries when that is NOT the key the + * case reads — the spelling a misplacing implementation would file the item + * under, which the refusal's message must NAME and its absence probes cover. + */ +function disagreeingDocumentName(testCase: MetadataRoundTripCase): string | undefined { + const written = testCase.writes[testCase.writes.length - 1]?.data; + const documentName = (written as { name?: unknown } | undefined)?.name; + return typeof documentName === 'string' && documentName !== testCase.read.name + ? documentName + : undefined; +} + +/** + * A `refused` row (#7378 rows 1/3): the single write rejects with the + * ADR-0112 envelope (`code` AND `status` — a rejection test that checks only + * "it threw" is not one), locates the problem in its message, and stores + * NOTHING — neither under the argument key nor under the document's own name. + */ +async function assertRefused(service: IMetadataService, testCase: MetadataRoundTripCase): Promise { + expect(testCase.writes).toHaveLength(1); + const write = testCase.writes[0]; + + const error = await service.register(write.type, write.name, write.data).then( + () => undefined, + (thrown: unknown) => thrown as Error & { code?: string; status?: number }, + ); + expect(error, `register must REFUSE this write (#7378): ${testCase.id}`).toBeDefined(); + expect(error).toMatchObject({ + code: StandardErrorCode.enum.VALIDATION_ERROR, + status: 400, + }); + + // 报错定位 — the message names the write's coordinates… + const message = String(error?.message ?? ''); + expect(message).toContain(`'${write.type}'`); + expect(message).toContain(`'${write.name}'`); + // …and, on the mismatch rows, BOTH disagreeing spellings. + const disagreeing = disagreeingDocumentName(testCase); + if (disagreeing !== undefined) { + expect(message).toContain(`'${disagreeing}'`); + } + + // The refusal wrote nothing: absent under the argument key… + expect(await service.get(testCase.read.type, testCase.read.name)).toBeUndefined(); + expect(await service.exists(testCase.read.type, testCase.read.name)).toBe(false); + const names = await service.listNames(testCase.read.type); + expect(names).not.toContain(testCase.read.name); + // …and never under the document's own name either — the misplacement the + // ruling exists to make impossible. + if (disagreeing !== undefined) { + expect(await service.get(testCase.read.type, disagreeing)).toBeUndefined(); + expect(names).not.toContain(disagreeing); + } +} + /** Replay a case's setup, then answer its single read through all four members. */ async function replay(service: IMetadataService, testCase: MetadataRoundTripCase) { for (const write of testCase.writes) { @@ -85,6 +187,11 @@ describe('IMetadataService round-trip conformance — contract reference double' it.each(METADATA_ROUNDTRIP_CASES.map((testCase) => [testCase.id, testCase] as const))( '%s', async (_id, testCase) => { + if (testCase.expected.kind === 'refused') { + await assertRefused(createReferenceService(), testCase); + return; + } + const { got, exists, names } = await replay(createReferenceService(), testCase); if (testCase.expected.kind === 'readable') { diff --git a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts index 10d4f83c22..e86de18956 100644 --- a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts +++ b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts @@ -45,27 +45,28 @@ * * ## What `expected` means, precisely * - * **The reference semantics: a store keyed by `type` × the `name` ARGUMENT.** - * That is what the contract's own double implements, what its parameter names - * say (`@param name - Item name/identifier (snake_case)` on both members) — - * and, since the maintainer ruling of 2026-08-11 on #7378 (option (a)), what - * {@link ../contracts/metadata-service | IMetadataService.register} RULES in - * so many words: the argument is the effective key and `data.name` never - * overrides it. + * **The reference semantics, ruled by the maintainer on 2026-08-12 (#7378, + * three cells): a store keyed by the CANONICAL type × the `name` ARGUMENT, + * refusing loudly what it cannot key.** * - * That ruling settled two of the three rows `MetadataFacade` used to answer - * differently — the effective key (`key-is-the-name-argument-*`) and the - * dropped non-object `data` (`primitive-data-roundtrips` and its array - * sibling) — and `MetadataFacade` was aligned to the contract on both. What - * they pin in the objectql driver is now RULED BEHAVIOUR, not a measured - * divergence. + * - a `data.name` disagreeing with the `name` argument is refused + * (the `refused` rows) — a disagreement is almost always an authoring bug, + * and silent resolution in either direction can misplace the item; + * - a non-object (or array) `data` is refused — never accepted-and-dropped, + * never coerced into storability; + * - the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) + * before any store decision, so the two spellings of the object type + * address ONE store. * - * ONE row is still measured-and-unresolved: `plural-objects-type-is-its-own-store`. - * The alias it measures is `SchemaRegistry`'s, not the facade's, and removing - * it collides with the platform's own plural→singular normalization direction — - * the objectql driver's surviving `// DIVERGENCE` note carries the evidence and - * the escalation. Pinning ≠ blessing: read that note and the card it links - * before treating either answer as the intended one. + * This supersedes the 2026-08-11 option-(a) reference semantics (a + * disagreeing `data.name` silently outranked by the argument; non-object + * `data` accepted; raw-string type keys) that this table's `expected` column + * stated until the ruling's spec-side half landed. Every shipped + * implementation enforces the same three cells through ONE shared guard — + * `assertMetadataRegisterContract` / `canonicalMetadataServiceType` + * (`@objectstack/core`, whose header carries the verbatim ruling); the + * reference double beside this table restates the semantics locally, because + * `packages/spec` is the dependency root and cannot import core. * * ## Deliberate scope * @@ -107,13 +108,23 @@ export interface MetadataRoundTripRemoval { * (`organization_id`, `created_at`, …) that the author never wrote. A driver * whose subject is known to answer verbatim SHOULD additionally assert exact * equality; the objectql driver does this via `documentFidelity`. + * + * `refused` (#7378, 2026-08-12) means the case's SINGLE write must be + * rejected with the ADR-0112 envelope — the standard catalog's + * `VALIDATION_ERROR` as the error's `code` AND `status` 400; a bare + * `toThrow()` is not a conformance assertion — with a locating message that + * names the type, the `name` argument and, on mismatch rows, BOTH disagreeing + * spellings; and with NOTHING stored: the case's read key answers absent, and + * so does the document's own `name` when it disagrees (the misplacement the + * ruling exists to make impossible). */ export type MetadataRoundTripExpectation = | { readonly kind: 'readable'; readonly document: unknown } - | { readonly kind: 'absent' }; + | { readonly kind: 'absent' } + | { readonly kind: 'refused' }; export interface MetadataRoundTripCase { - /** Stable id — what a divergence override in a driver keys off. */ + /** Stable id — unique per row; what a driver names when it reports a row. */ readonly id: string; /** Sentence stating the proposition, used as the test title. */ readonly title: string; @@ -121,7 +132,10 @@ export interface MetadataRoundTripCase { readonly writes: readonly MetadataRoundTripWrite[]; /** Applied after every write, through `unregister(type, name)`. */ readonly removes?: readonly MetadataRoundTripRemoval[]; - /** The ONE read the case makes, through `get` / `exists` / `listNames`. */ + /** + * The ONE read the case makes, through `get` / `exists` / `listNames`. + * On a `refused` row it is the key the refusal must have left absent. + */ readonly read: { readonly type: string; readonly name: string }; readonly expected: MetadataRoundTripExpectation; /** Why the case is in the table — what breaks if it is dropped. */ @@ -258,44 +272,44 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ why: 'No shipped implementation normalizes name case today. Pinning that keeps a future one from folding case silently — which would make two authored items collide into one.', }, { - id: 'key-is-the-name-argument-object', - title: "get('object', n) finds a write made under n even when data.name differs", + id: 'data-name-mismatch-refused-object', + title: "register('object', n, d) REFUSES a d whose own name disagrees with n", writes: [{ type: 'object', name: 'pin_key', data: PIN_KEYED_OBJECT }], read: { type: 'object', name: 'pin_key' }, - expected: { kind: 'readable', document: PIN_KEYED_OBJECT }, - why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (#7378, maintainer 2026-08-11, option (a)): the argument is the effective key and `data.name` never overrides it. Every shipped implementation now answers this way; `MetadataFacade` was aligned to it in the same PR.', + expected: { kind: 'refused' }, + why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (#7378, maintainer 2026-08-12, row 1, superseding the 2026-08-11 option (a) this row used to state): a disagreement is refused loudly — it is almost always an authoring bug, and silent resolution in EITHER direction can file the item under a key the author never wrote. A document with no `name` of its own still registers under the argument; the objectql driver pins that boundary.', }, { - id: 'key-is-the-name-argument-nonobject', - title: "get('view', n) finds a write made under n even when data.name differs", + id: 'data-name-mismatch-refused-nonobject', + title: "register('view', n, d) REFUSES a d whose own name disagrees with n", writes: [{ type: 'view', name: 'pin_key_view', data: PIN_KEYED_VIEW }], read: { type: 'view', name: 'pin_key_view' }, - expected: { kind: 'readable', document: PIN_KEYED_VIEW }, - why: 'The same question on the generic store, so a divergence cannot be mistaken for object-specific special-casing.', + expected: { kind: 'refused' }, + why: 'The same refusal on the generic store, so a conforming answer cannot be mistaken for object-specific special-casing.', }, { - id: 'plural-objects-type-is-its-own-store', - title: "get('object', n) does not see a register('objects', n, …)", + id: 'plural-type-folds-to-canonical-store', + title: "get('object', n) sees a register('objects', n, …) — the plural spelling folds to the canonical type", writes: [{ type: 'objects', name: 'pin_plural', data: PIN_PLURAL }], read: { type: 'object', name: 'pin_plural' }, - expected: { kind: 'absent' }, - why: 'The two spellings of the object type. The reference store keys on the string it is given; some implementations alias the plural to the singular. **Shipped implementations still disagree here, and this row is the one #7378 did NOT settle** — the alias is `SchemaRegistry`\'s own, on the READ side, and the objectql driver\'s surviving DIVERGENCE note carries the measurement and the escalation.', + expected: { kind: 'readable', document: PIN_PLURAL }, + why: "The two spellings of the object type address ONE store. **Ruled** (#7378, maintainer 2026-08-12, row 2): every implementation gives one answer, converged with `check:meta-type-normalized`'s enforced direction — the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) before any store decision. This row was `plural-objects-type-is-its-own-store` (expected `absent`) while the answer was still a measured divergence; the reverse read direction is pinned driver-locally in objectql.", }, { - id: 'primitive-data-roundtrips', - title: 'a non-object `data` value is readable back unchanged', + id: 'primitive-data-refused', + title: 'a non-object `data` value is REFUSED, never accepted-and-dropped', writes: [{ type: 'setting', name: 'pin_flag', data: 'enabled' }], read: { type: 'setting', name: 'pin_flag' }, - expected: { kind: 'readable', document: 'enabled' }, - why: '`data` is declared `unknown`, not `object`. An implementation that derives its key from `data.name` has nothing to derive it from here — which is why the #7378 ruling (the ARGUMENT is the key) is what makes this row answerable at all. Accepting the write and dropping the value is the one answer the ruling forbids.', + expected: { kind: 'refused' }, + why: '`data` is declared `unknown`, not `object`, so this is a runtime refusal (#7378, maintainer 2026-08-12, row 3): a value the service cannot key was measured as accept-then-drop — written, then readable back through NO member — which is indefensible; and coercing it into storability is equally forbidden. The ruling fixes 「接受再丢」, it does not demand 「必须存下」.', }, { - id: 'array-data-roundtrips', - title: 'an array `data` value is readable back as an array', + id: 'array-data-refused', + title: 'an array `data` value is REFUSED with the primitives', writes: [{ type: 'setting', name: 'pin_list', data: PIN_ARRAY }], read: { type: 'setting', name: 'pin_list' }, - expected: { kind: 'readable', document: PIN_ARRAY }, - why: 'The sibling shape of the row above, and the one a `typeof data === "object"` guard gets WRONG rather than drops: an array passes that test, so an implementation that keys by spreading the document turns [a, b] into { 0: a, 1: b } — silent corruption where the primitive row measured silent loss. Neither survives the #7378 ruling.', + expected: { kind: 'refused' }, + why: 'The sibling shape of the row above, and the one a `typeof data === "object"` guard admits WRONGLY: an array passes that test, carries no document identity, and an implementation that keys by spreading the document turns [a, b] into { 0: a, 1: b } — silent corruption where the primitive row measured silent loss. The ruling\'s ban on coercion-into-storability decides this row with the primitive one.', }, { id: 'absent-after-unregister', diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index b8aa1ba504..82928b3e8e 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -226,38 +226,47 @@ export interface IMetadataService { * `options.notify === false` — see {@link MetadataWriteOptions.notify} * before silencing it. * - * ## The `name` ARGUMENT is the effective key (#7378) + * ## The #7378 register contract (maintainer ruling 2026-08-12, three cells) * - * `name` — not `data.name` — is what this item is stored under, and it is + * `name` — the ARGUMENT — is what this item is stored under, and it is * what {@link get}, {@link exists}, {@link listNames} and - * {@link unregister} address it by. When a document carries a `name` of its - * own and the two disagree, **the argument wins and `data.name` never - * overrides it**: `register(t, n, d)` followed by `get(t, n)` holds for - * every `d`, including one whose own `name` says something else, and - * including a `data` that is not an object at all and so has no `name` to - * derive (`data` is declared `unknown`, not `object`). - * - * An implementation whose store derives the key from the document — - * `SchemaRegistry`, whose objects are keyed by their own identity — must - * reconcile the document to the argument rather than the other way round; - * what it may NOT do is accept the write and file it somewhere the - * caller's own `name` cannot reach it. `METADATA_ROUNDTRIP_CASES` - * (`./metadata-service-roundtrip-conformance`) is the executable form of - * this paragraph. - * - * Maintainer ruling of 2026-08-11 on #7378, option (a), taken because it is - * what 4 of the 5 measured implementations already did and what this - * member's own `@param name` already implied. Option (c) — **refuse a - * `data.name !== name` disagreement loudly** instead of silently resolving - * it either way — is recorded there as the long-term strictness candidate - * and PARKED for v18 consideration: it is the only answer that cannot - * misplace an item, and it is a behaviour change on every implementation, - * so it is not this contract's rule today. Do not implement it ahead of - * that ruling. - * - * @param type - Metadata type (e.g. 'object', 'view', 'flow') + * {@link unregister} address it by. The ruling closes the three ways an + * implementation used to bend that, and every shipped implementation + * enforces all three through one shared guard + * (`assertMetadataRegisterContract` / `canonicalMetadataServiceType`, + * `@objectstack/core` — its header carries the maintainer's verbatim + * ruling): + * + * - **A `data.name` disagreeing with the `name` argument is REFUSED + * loudly** — the standard catalog's `VALIDATION_ERROR`, status 400, a + * locating message naming the type, the argument and BOTH disagreeing + * spellings, and nothing stored. A disagreement is almost always an + * authoring bug, and resolving it silently in either direction can file + * the item under a key the caller never wrote. A document with NO + * `name` of its own registers under the argument — absence is not a + * disagreement. + * - **A non-object `data` is REFUSED** (primitives, `null`, arrays) — the + * same envelope — never accepted-and-dropped and never coerced into + * storability. `data` is declared `unknown`, so this is a runtime + * refusal, not a type error; arrays are refused with primitives because + * an array carries no document identity and `{ ...[a, b] }` is + * `{ 0: a, 1: b }` corruption. + * - **Type stores key on the CANONICAL type**: a plural manifest spelling + * folds to the singular metadata type name (`PLURAL_TO_SINGULAR`, + * `@objectstack/spec/shared`) before any store decision — converged + * with `check:meta-type-normalized`'s enforced direction — so + * `register('objects', n, d)` and `get('object', n)` address one store. + * + * This supersedes the 2026-08-11 option-(a) ruling (argument silently + * wins over a disagreeing `data.name`; non-object `data` accepted), which + * an earlier revision of this TSDoc stated as current. + * `METADATA_ROUNDTRIP_CASES` (`./metadata-service-roundtrip-conformance`) + * is the executable form of this section, replayed against the contract's + * own reference double and every shipped implementation. + * + * @param type - Metadata type (e.g. 'object', 'view', 'flow'); a plural spelling folds to the canonical singular * @param name - Item name/identifier (snake_case) — the effective storage key - * @param data - The metadata definition to register; a `name` it carries does NOT displace the argument + * @param data - The metadata definition to register: a plain object whose own `name`, if present, agrees with the `name` argument — anything else is refused * @param options - Write options; `{ notify: false }` suppresses the watcher event */ register(type: string, name: string, data: unknown, options?: MetadataWriteOptions): Promise; @@ -284,12 +293,13 @@ export interface IMetadataService { /** * Get a metadata item by type and name * - * `name` is the key {@link register} was called with — the ARGUMENT, never - * a `name` the stored document happens to carry (#7378, maintainer ruling - * 2026-08-11). So `register(t, n, d)` → `get(t, n)` resolves for every `d`, - * and the document's own `name` disagreeing with `n` changes nothing about - * where it is found. See {@link register} for the full statement and for - * the strictness option parked behind it. + * `name` is the key {@link register} was called with — the ARGUMENT. A + * `data.name` disagreeing with it is refused at register time (#7378, + * maintainer ruling 2026-08-12), so a stored document's own `name`, when + * it has one, always agrees with the key it is found under. `type` folds + * to the canonical singular spelling exactly as `register` folds it, so + * both spellings of a type address one store. See {@link register} for + * the full three-cell statement. * * `undefined` is AMBIGUOUS by construction — it means "not found" *and* * "every loader that could hold it failed". Prefer {@link getDiagnosed}