Skip to content

Commit 29d055b

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): assert the canonical type at the SchemaRegistry overlay mint door (#9173)
* fix(metadata-protocol): assert the canonical type at the registry mint door (#9111) `hydrateOverlayIntoRegistry` minted registry entries under the raw `type` with no fold and no assertion — the last unfolded seam of the `objects` tolerance family. The "callers must fold" contract lived in no type, no signature and no check. Measured across all six producer routes first: four fold completely, and two (boot `loadMetaFromDb`, `revertCommit`) fold through the MANIFEST-collection map, which is tolerant AND incomplete — it passes through the six spellings whose types are not stack collections. Dormant for live traffic; reachable only from pre-#7894 at-rest residue. Closed as an ASSERT rather than a fold: folding would launder a row past the NOT_OVERRIDABLE lock that #7894 closed the door on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * docs(spec): regenerate references for the new error code; record observed ablation directions (#9111) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * test(metadata-protocol): drop unexercised write verbs from the #9111 engine double Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(spec): regenerate references from the merged tree (#9111) Discharges the os-regen deferral recorded against the merge commit. Wholesale gen:schema + gen:docs on the committed merge, never hand-resolved: both roll-ups moved by exactly +1 (1x +272->+273, 66x +276->+277) and the neighbour control confirms #8846's QUERY_OBJECT_MISMATCH survived alongside REGISTRY_TYPE_NOT_CANONICAL on both enumerating pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(spec): regenerate references from the merged tree (#9111) Discharges the os-regen deferral recorded against merge commit b4c842c. Wholesale gen:schema + gen:docs on the committed merge, plus gen:openapi to restore what gen:schema's cleanup wipes. No generated file hand-resolved. Neighbour control re-run ledger-wide rather than on a handful: all 241 ERROR_CODE_LEDGER codes scanned across 213 reference pages, zero in an empty page-set. 225 share the contract.mdx + error-code-ledger.mdx baseline (REGISTRY_TYPE_NOT_CANONICAL among them); every outlier is a superset that also names the code in domain prose, never a page short. Roll-ups moved by exactly one (1x +281->+282, 66x +285->+286). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(docs): regenerate the API reference after the round-3 merge with main Discharges the os-regen deferral recorded by the merge commit. Wholesale `gen:schema && gen:docs && gen:openapi` from the merged tree, per scripts/pm/os-regen-merge.sh step 4. The regenerated diff against main is now 2 pages, not 11: #9239 landed on main since round 2 and stopped the in-shape enum marker from restating vocabulary cardinality, so a single new error code no longer bumps a roll-up count on nine unrelated pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(docs): regenerate the API reference after the round-4 merge with main Discharges the os-regen deferral recorded by merge 0da2465. Wholesale `gen:schema && gen:docs && gen:openapi` from the merged tree. Round 4 reconciles against #9269, which registered UNIQUE_SCOPE_CONFIRMATION_REQUIRED (#9246) after the round-3 regen — the staleness that dequeued this PR from the merge queue with CI_FAILURE on `check:docs` (contract.mdx out of date). Both codes are present; the generated footprint is the two-page floor: contract.mdx + error-code-ledger.mdx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a1b45e commit 29d055b

7 files changed

Lines changed: 505 additions & 9 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
Refuse a non-canonical metadata `type` at the SchemaRegistry overlay mint door
7+
8+
`hydrateOverlayIntoRegistry` — the one choke point boot hydration, the read-side
9+
hydration and the write-through all funnel through — minted registry entries under
10+
whatever `type` spelling it was handed, with no fold and no assertion. It now asserts
11+
the spelling is canonical and refuses with `REGISTRY_TYPE_NOT_CANONICAL` (status 500)
12+
when it is not, so an entry can no longer be minted into a second registry namespace
13+
that no canonical read, listing or declaration lookup can reach.
14+
15+
Four of the six producer routes already folded at the boundary. The two that did not
16+
(boot hydration and `revertCommit`) fold through the manifest-collection map, which
17+
omits the types that are not stack collections — so it resolved the plurals that were
18+
never the hazard and passed through the ones that were. Reachable only from metadata
19+
rows written before the `/meta` URL boundary began folding; such a row is now reported
20+
loudly (counted and named at boot, warned on the write-through) instead of silently
21+
registering under its stored spelling.
22+
23+
Deliberately an assertion rather than a fold: folding here would honour, process-wide,
24+
the override that the canonical `/meta` door refuses.

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +282 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +283 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) |
3232
| **message** | `string` || Readable error message |
3333
| **category** | `string` | optional | Error category (e.g. validation, authorization) |
@@ -261,6 +261,7 @@ const result = ApiErrorSchema.parse(data);
261261
* `RECORD_GONE`
262262
* `RECORD_LOCKED`
263263
* `RECORD_NOT_ELIGIBLE`
264+
* `REGISTRY_TYPE_NOT_CANONICAL`
264265
* `REPORTS_LIST_FAILED`
265266
* `REPORT_DELETE_FAILED`
266267
* `REPORT_GET_FAILED`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ const result = ErrorCode.parse(data);
366366
* `RECORD_GONE`
367367
* `RECORD_LOCKED`
368368
* `RECORD_NOT_ELIGIBLE`
369+
* `REGISTRY_TYPE_NOT_CANONICAL`
369370
* `REPORTS_LIST_FAILED`
370371
* `REPORT_DELETE_FAILED`
371372
* `REPORT_GET_FAILED`

packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts

Lines changed: 358 additions & 0 deletions
Large diffs are not rendered by default.

packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@
9191
// shadowing path; it did not add a second line of defence, and folding at the
9292
// producer remains the only thing that actually prevents a plural key.
9393
//
94+
// [#9111] That paragraph was the card this one was filed as. The fall-through
95+
// half is now CLOSED: `hydrateOverlayIntoRegistry` asserts its type is
96+
// canonical (`REGISTRY_TYPE_NOT_CANONICAL`, status 500) rather than minting
97+
// under the raw spelling, so an unfolded caller is refused at the mint door
98+
// too. Its final sentence still stands unchanged and is the reason the assert
99+
// is an assert and not a fold: folding at the producer remains the only thing
100+
// that PREVENTS a plural key — the assert only guarantees that failing to do
101+
// so is loud. Measured trace and the fold-map hole it closes:
102+
// `protocol.hydrate-overlay-canonical-type.test.ts`.
103+
//
94104
// ---------------------------------------------------------------------------
95105
// Ablation directions, predicted BEFORE running (results in the PR bodies)
96106
// ---------------------------------------------------------------------------

packages/metadata-protocol/src/protocol.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11439,12 +11439,105 @@ export class ObjectStackProtocolImplementation implements
1143911439
* Returns whether anything was registered (org-scoped rows, bodies
1144011440
* without a `name`, and registry doubles without `registerItem`, are
1144111441
* no-ops).
11442+
*
11443+
* ## [#9111] `type` is an ASSERTED input, not a silently-trusted one
11444+
*
11445+
* The spelling handed in is the spelling the registry entry is minted
11446+
* under — `registerItem(type, …)` below, and the same `type` again in
11447+
* {@link hydrateExpandedViewItems}. Until this assert the parameter was a
11448+
* bare `type: string` with a load-bearing "callers must fold" contract
11449+
* that lived in no type, no signature and no check. That family has cost
11450+
* four cards (#8820, #8862, #9009, #9111); this is the fifth rediscovery
11451+
* not happening.
11452+
*
11453+
* **Measured across every producer before asserting** (the card's phase 1
11454+
* — six routes, and the read side FIRST, because #9157 had just falsified
11455+
* "every `/meta` entry point folds" one function over):
11456+
*
11457+
* 1. `getMetaItems` (read-side hydration) — `canonicalizeMetaRequestType`
11458+
* 2. `saveMetaItem` → write-through — `canonicalizeMetaRequestType`
11459+
* 3. `rollbackMetaItem` → write-through — `canonicalizeMetaRequestType`
11460+
* 4. `promoteDraftForPublish` → write-through — `PLURAL_TO_SINGULAR`, but
11461+
* both its callers are covered: `publishMetaItem` folds at the
11462+
* boundary, and `publishPackageDrafts` is pre-empted by #8908's
11463+
* `STORED_TYPE_NOT_CANONICAL` pre-flight ({@link isNonCanonicalStoredType}).
11464+
* 5. `revertCommit` → write-through — `PLURAL_TO_SINGULAR[it.type]`
11465+
* over a STORED commit-item type. **Unguarded.**
11466+
* 6. `loadMetaFromDb` (boot) — `PLURAL_TO_SINGULAR[record.type]`
11467+
* over a STORED row type. **Unguarded.**
11468+
*
11469+
* Routes 5 and 6 fold through the MANIFEST-COLLECTION map, which is
11470+
* TOLERANT AND INCOMPLETE in exactly the way #9161 named one seam over: it
11471+
* resolves the plurals that were never the hazard and passes through the
11472+
* six spellings whose types are not stack collections (`fields`, `seeds`,
11473+
* `external_catalogs`, `externalCatalogs`, `translations`,
11474+
* `email_templates`). For those the fold is a NO-OP and the raw spelling
11475+
* reaches `registerItem` — a second registry namespace for an item that has
11476+
* a canonical home, invisible to every canonical read.
11477+
*
11478+
* DORMANT for live traffic, and that is a measurement, not an assumption:
11479+
* the only `sys_metadata` writer that stamps a caller-chosen `type`
11480+
* (`saveMetaItem`'s `repo.put`) folds at the boundary, so no live write can
11481+
* mint such a row. The population that reaches routes 5 and 6 is pre-#7894
11482+
* AT-REST residue, which is real and which nothing rewrites on upgrade.
11483+
*
11484+
* ⛔ The refusal is an ASSERT and deliberately NOT a fold. Folding here
11485+
* would be the tolerant lookup below a folding boundary that
11486+
* {@link canonicalMetaType}'s header has rejected since #4432 — and it
11487+
* would do something worse than dilute a contract: a pre-#7894 row exists
11488+
* BECAUSE `PUT /meta/fields/…` slipped past the lock that answers
11489+
* `PUT /meta/field/…` with 403 NOT_OVERRIDABLE. Quietly folding it into the
11490+
* canonical key at boot would honour, process-wide, precisely the override
11491+
* #7894 closed the door on. Refusing leaves the row exactly as unreachable
11492+
* as it is today and says so out loud instead.
11493+
*
11494+
* Loudness per route, since neither is a `/meta` request with a caller to
11495+
* answer to: at boot the throw is caught by `loadMetaFromDb`'s per-record
11496+
* handler, so the row is counted in `errors` and named in a warning
11497+
* (registering nothing was already the honest outcome — a `'fields'` entry
11498+
* serves no reader); on the write-through it degrades to
11499+
* {@link applyRegistryWriteThrough}'s best-effort `console.warn`, which is
11500+
* correct there and not a softened assert — the row is already persisted,
11501+
* and #4521's contract is that a registry hiccup must never fail a
11502+
* committed write.
11503+
*
11504+
* ⚠️ This assert touches NO audit row, NO commit record and NO repository
11505+
* key. It is scoped to the registry mint door, and no producer's spelling
11506+
* is changed by it — so #8908's `AUDIT_TYPE_NOT_CANONICAL` ruling (which
11507+
* requires the caller's spelling to reach the audit writer unfolded, and to
11508+
* fail loudly at 500 when it is wrong) is untouched in both directions.
11509+
*
11510+
* What it cannot refuse, by construction: a canonical type (no canonical
11511+
* spelling folds elsewhere), and a plugin-registered or otherwise
11512+
* unrecognised kind — {@link canonicalMetaType} is the identity for
11513+
* anything the static map does not carry, so a runtime kind the platform
11514+
* has never heard of can never trip this gate.
1144211515
*/
1144311516
private hydrateOverlayIntoRegistry(
1144411517
type: string,
1144511518
data: unknown,
1144611519
options: { packageId?: string | null; organizationId: string | null },
1144711520
): boolean {
11521+
// [#9111] See the header: the mint door asserts, the caller folds.
11522+
// Placed FIRST, ahead of every no-op return below, so the contract is
11523+
// judged on the spelling itself rather than on whether this particular
11524+
// row happened to be registrable — a caller that stops folding must not
11525+
// be able to hide behind an org-scoped or nameless body.
11526+
const canonicalType = canonicalMetaType(type);
11527+
if (canonicalType !== type) {
11528+
const err: any = new Error(
11529+
`[registry_type_not_canonical] Refusing to register a SchemaRegistry overlay entry under `
11530+
+ `the non-canonical metadata type '${type}' (canonical: '${canonicalType}'). The registry `
11531+
+ `holds exactly one plain key per (type, name) and every reader addresses it through the `
11532+
+ `'/meta' boundary, which folds — an entry minted under '${type}' is a second namespace no `
11533+
+ `canonical read, listing or declaration lookup can reach (#4432). Fold the type at the `
11534+
+ `producer (canonicalMetaType), not here; see this method's header for why the mint door `
11535+
+ `refuses instead of folding.`,
11536+
);
11537+
err.code = 'REGISTRY_TYPE_NOT_CANONICAL';
11538+
err.status = 500;
11539+
throw err;
11540+
}
1144811541
// [#6602] ADR-0005 — a per-org overlay is served on demand, never
1144911542
// grafted into the registry every org in this process shares.
1145011543
if (options.organizationId !== null && options.organizationId !== undefined) return false;
@@ -11598,15 +11691,23 @@ export class ObjectStackProtocolImplementation implements
1159811691
// passing `'objects'` no longer takes this branch — so it no longer
1159911692
// reaches `registerObject`, and the object is simply not registered:
1160011693
// `assertObjectRegistered` fails CLOSED, a loud recoverable error in
11601-
// place of a silent one. That is the win. It does NOT mean nothing is
11602-
// registered at all: on an unscoped kernel the value falls through to
11694+
// place of a silent one. That is the win.
11695+
//
11696+
// [#9111] The rest of this paragraph is now HISTORY, and is kept in the
11697+
// past tense because it is what the next card was filed about. It used
11698+
// to read: "It does NOT mean nothing is registered at all — on an
11699+
// unscoped kernel the value falls through to
1160311700
// {@link hydrateOverlayIntoRegistry}, which registers under the RAW
11604-
// type like every other overlay kind. So the plural is no longer an
11605-
// object-specific hazard minting a shadow OBJECT — it is merely the
11606-
// same general "producers must fold" contract every other metadata
11607-
// type already lives under. Folding at the producer stays the rule;
11608-
// this guard is not a second line of defence and must not be written
11609-
// as one.
11701+
// type like every other overlay kind." That was true, and it was the
11702+
// last unfolded seam of this family. It no longer holds: the hydrator
11703+
// now ASSERTS its type is canonical (`REGISTRY_TYPE_NOT_CANONICAL`),
11704+
// so an unfolded plural arriving here is refused at the mint door
11705+
// rather than minting a shadow entry under the raw spelling.
11706+
//
11707+
// ⛔ That is still not a second line of defence, and must not be
11708+
// written as one: folding at the producer stays the rule, and the
11709+
// assert exists to make a producer that stops folding FAIL LOUDLY
11710+
// instead of silently — not to repair its key.
1161011711
if (request.type === 'object') {
1161111712
// NOT org-gated, deliberately: an `object` is `allowOrgOverride:
1161211713
// false` (ADR-0005) and its physical TABLE is env-wide, so the

packages/spec/src/api/error-code-ledger.zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export const ERROR_CODE_LEDGER = {
376376
// and `/packages` serve protocol throws through `errorFromThrown`.
377377
// Reported by the #8087 dispatcher-vocabulary gate.
378378
'QUERY_OBJECT_MISMATCH',
379+
'REGISTRY_TYPE_NOT_CANONICAL', // [#9111] a SchemaRegistry overlay entry was offered a non-canonical metadata `type` — the mint door asserts, the caller folds
379380
'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793)
380381
'STORED_TYPE_NOT_CANONICAL', // [#8908] a package draft is stored under a non-canonical metadata type (pre-#7894 second-namespace residue) — refused at the publish pre-flight, batch-atomic
381382
'TENANT_SCOPE_REQUIRED', // [#7780] destructive call named neither an organization nor an explicit cross-tenant intent; needs an explicit opt-in

0 commit comments

Comments
 (0)