From ed10c875b52472555fb3ec8d52470f081e04858f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 11:26:14 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): route rollbackMetaItem through the canonical type fold (#8819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollbackMetaItem` was the eighth `/meta` entry point and the last one still deriving its type key from `PLURAL_TO_SINGULAR` instead of `canonicalizeMetaRequestType`. It passed the RAW caller spelling to `assertLockAllowsWrite` while every row operation below it used the folded key, so `getEffectiveLock`'s overlay limb — which queries `sys_metadata` with the raw `type` — looked an ADR-0010 `_lock` up under a type no row carries, returned 'none' (a verdict, not a neutral value: #5706) and let the restore proceed against the folded key. Pinned as group D of protocol-publish-canonical-fold.test.ts, with a positive control so the group cannot pass by being unable to roll back at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .changeset/rollback-canonical-type-fold.md | 62 +++++++++ packages/metadata-protocol/src/protocol.ts | 53 +++++++- .../protocol-publish-canonical-fold.test.ts | 124 +++++++++++++++++- 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 .changeset/rollback-canonical-type-fold.md diff --git a/.changeset/rollback-canonical-type-fold.md b/.changeset/rollback-canonical-type-fold.md new file mode 100644 index 0000000000..4627ddda9c --- /dev/null +++ b/.changeset/rollback-canonical-type-fold.md @@ -0,0 +1,62 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): `rollbackMetaItem` routes through the canonical type fold, closing an ADR-0010 `_lock` a plural URL spelling could address around (#8819) + +`rollbackMetaItem` is the **eighth** `/meta` entry point on the +`POST /api/v1/meta/:type/:name/rollback` URL family, and it was the last one +still deriving its type key from `PLURAL_TO_SINGULAR` — the +MANIFEST-COLLECTION map #7894 moved this boundary off — instead of +`canonicalizeMetaRequestType`. The other seven fold; this one did not. + +**The half of that asymmetry that was not fail-closed is the lock.** +`assertLockAllowsWrite` delegates to `getEffectiveLock`, whose artifact limb +folds and whose **overlay limb queries `sys_metadata` with the raw `type`**. The +rollback passed the caller's spelling to the gate while every row operation +below it used the folded key. So for a manifest-present type, a rollback +addressed `/meta/views/case_grid/rollback` looked the `_lock` up under a `type` +no row carries, got `'none'` back — which is not a neutral value but the verdict +"the author declared no protection" (#5706) — and then restored the history body +against the folded key, which resolves the protected row perfectly. A lock gate +addressable around from the wire, on the verb that overwrites the active body. + +**The severity window is narrow and is not rounded up here.** It needs an +environment kernel (`assertLockAllowsWrite` opens with +`if (this.environmentId === undefined) return null`, skipping the gate wholesale +otherwise) **and** a lock carried by a **stored overlay row** rather than a +packaged artifact — the artifact limb folds, so an artifact `_lock` was already +found under either spelling. Inside that window the write landed. + +The fold also reaches three things that were merely incoherent rather than +unsafe: the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`) +took the permissive **plugin** branch for the four manifest-absent types +(`field`, `seed`, `external_catalog`, `translation`); and the +`[not_overridable]` refusal, both ADR-0010 audit rows and both receipt sentences +reported the **caller's** spelling for a row written under the canonical one. +`recordMetadataAudit` re-folds internally through `PLURAL_TO_SINGULAR`, which +covers a manifest-present plural and misses the four manifest-absent ones — so +folding at the boundary is what makes the audit trail agree with the write for +both classes. + +Placed after the existing `toVersion` envelope guard rather than at the very top +of the method: that is the position `saveMetaItem` documents for this exact pair, +naming this method's opening guard its structural twin — a malformed request +envelope is refused before its type key is canonicalised, and both refusals are +`[invalid_request]`/400 either way. + +**What this does not do.** `getEffectiveLock`'s overlay limb still queries the +raw `type`. Folding it there would close the class at the producer for every +present and future caller, which is the contract-first shape — but it is a +shared gate whose blast radius wants its own measurement, so it is deliberately +left open as its own card rather than ridden in here. + +Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` as +group D, driving the real `ObjectQL` / protocol / `SysMetadataRepository` over an +in-memory driver on an environment kernel: the canonical spelling is refused by +the lock, the plural spelling is refused by the **same** lock, and — the clause +that matters, since the first two can both pass while the write still lands — +the protected active body is **unchanged** afterwards. A positive control runs +the identical plural call with the lock removed and asserts it really does +restore the earlier body, so the group cannot pass by being unable to roll back +at all. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2612f40427..e059999c41 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15446,7 +15446,55 @@ export class ObjectStackProtocolImplementation implements err.status = 400; throw err; } - const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; + // #4432 / #7894 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. + // `rollbackMetaItem` is the EIGHTH `/meta` entry point on this URL + // family (`POST /api/v1/meta/:type/:name/rollback`, caller-supplied + // `:type`) and was the last one still deriving its type key from + // `PLURAL_TO_SINGULAR` — the MANIFEST-COLLECTION map #7894 moved this + // boundary off. Placed AFTER the envelope guard above rather than at + // the very top: that is the position {@link saveMetaItem} documents for + // this exact pair, calling this method's opening guard its structural + // twin — a malformed request envelope is refused before its type key is + // canonicalised, and both refusals are `[invalid_request]`/400 anyway. + // + // What the fold reaches here, measured rather than assumed: + // + // • `getEffectiveLock`'s OVERLAY limb, which queries `sys_metadata` + // with the raw `type`. Its artifact limb folds; the overlay limb + // does not. So an ADR-0010 `_lock` carried by the stored ACTIVE row + // was looked up under a `type` no row carries and came back `'none'` + // — not a neutral value but the verdict "the author declared no + // protection" (#5706) — while the restore below read the folded key + // and resolved the protected row perfectly. A lock addressable + // around from the wire; the one limb of this verb that was not + // fail-closed, and the reason this card exists. + // • the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`) + // for the four MANIFEST-ABSENT types (`field`, `seed`, + // `external_catalog`, `translation`), which stayed plural through + // the manifest map and so took the PERMISSIVE PLUGIN branch — the + // #7894 shape, one verb over. + // • the `[not_overridable]` refusal, the two ADR-0010 audit rows and + // both receipt sentences, which read `request.type` and so reported + // the CALLER's spelling for a row written under the canonical one. + // `recordMetadataAudit` re-folds through `PLURAL_TO_SINGULAR` + // internally, which covers a manifest-present plural and misses the + // four manifest-absent ones; folding here is what makes the audit + // trail agree with the write for BOTH classes. + // + // ⛔ This does NOT close the class at its producer. `getEffectiveLock`'s + // overlay limb still queries the raw `type`, so a future caller that + // reaches the gate without folding first re-opens the same door. Folding + // there instead is the contract-first shape and is deliberately left + // open as its own card with its own blast-radius measurement — not + // answered here, and not a rider on this fix. + request = canonicalizeMetaRequestType(request); + // Canonical by construction from the fold above. NOT a second fold: the + // `PLURAL_TO_SINGULAR` lookup that used to stand here is exactly what + // #7894 removed from this boundary. The binding survives under its own + // name because the row operations below read it as "the key the row is + // stored under" — the same shape {@link deleteMetaItem} keeps for + // `singularTypeForRepo`. + const singularType = request.type; if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) { const err: any = new Error( @@ -15457,6 +15505,9 @@ export class ObjectStackProtocolImplementation implements throw err; } // ADR-0010 L3 — lock blocks rollback (writes a new active row). + // `request.type` is the CANONICAL spelling by the time it gets here + // (fold above), which is what makes this gate read the same row the + // restore below writes. It was the raw caller spelling until #8819. const _rollbackLockErr = await this.assertLockAllowsWrite({ type: request.type, name: request.name, diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index e5684a7030..baf2a1c09d 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -204,7 +204,11 @@ async function makeProtocol() { engine.registry.registerObject(sysMetadataObject, 'test-package'); const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_prod'); const rows = () => Array.from(stores.get('sys_metadata')?.values() ?? []) as any[]; - return { protocol, rows }; + // [#8819] The history table, for the rollback verb in group D — it restores + // a body FROM history, so the target version has to be read rather than + // assumed. Additive: groups A–C destructure what they need and ignore this. + const historyRows = () => Array.from(stores.get('sys_metadata_history')?.values() ?? []) as any[]; + return { protocol, rows, historyRows }; } /** The refusal envelope ADR-0112 pins: `code` AND `status`, never "it threw". */ @@ -406,3 +410,121 @@ describe('#8769 · a plural-addressed publish cannot address around the overlay expect(JSON.parse(active.metadata).label).toBe('v1'); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// D — #8819: the SAME overlay lock, one verb over — `rollbackMetaItem` +// ═══════════════════════════════════════════════════════════════════════════ +// +// `rollbackMetaItem` is the EIGHTH `/meta` entry point (`POST +// /api/v1/meta/:type/:name/rollback`, caller-supplied `:type`) and was the last +// one deriving its type key from `PLURAL_TO_SINGULAR` rather than the boundary +// fold. The seam is group C's exactly: `getEffectiveLock`'s overlay limb queries +// `sys_metadata` with the raw `type`, so a rollback addressed `views` read the +// lock under a key no row carries, got `'none'` — the verdict "the author +// declared no protection" (#5706) — and then restored the history body against +// the FOLDED key, which resolves the protected row perfectly. +// +// ⚠️ Why this verb gets its own group rather than inheriting group C's: a +// rollback writes a NEW ACTIVE ROW FROM HISTORY instead of promoting a draft, so +// the "protected body unchanged" clause is a materially different assertion. +// What a missed lock costs here is not an overwrite by a pending draft — it is +// the active body silently REVERTING to an older one, on an item whose author +// declared it must not be overlaid at all. +describe('#8819 · a plural-addressed rollback cannot address around the overlay `_lock`', () => { + /** + * Stage: `view/case_grid` published twice (`v1`, then `v2`), so history + * carries a restorable earlier version and the ACTIVE row carries `v2`. + * The lock is then written straight into the stored active row — the same + * device group C uses and for the same reason: `saveMetaItem` refuses a + * write once the lock is live, and "history that predates the lock" is + * exactly the state this door has to hold shut. + */ + async function stageLockedActiveOverRestorableHistory(opts: { locked: boolean }) { + const { protocol, rows, historyRows } = await makeProtocol(); + await (protocol as any).saveMetaItem({ + type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v1'), mode: 'draft', + }); + await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + await (protocol as any).saveMetaItem({ + type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v2'), + mode: 'draft', force: true, + }); + await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + + const activeRow = rows().find((r) => r.type === 'view' && r.state === 'active'); + expect(activeRow, 'fixture: an active row must exist to carry the lock').toBeTruthy(); + const body = typeof activeRow.metadata === 'string' + ? JSON.parse(activeRow.metadata) : activeRow.metadata; + expect(body.label, 'fixture: the active body is the SECOND publish').toBe('v2'); + if (opts.locked) { + activeRow.metadata = JSON.stringify({ ...body, _lock: 'no-overlay', _lockReason: 'test fixture' }); + } + + // The version to restore is READ, never assumed: the numbering depends + // on how many `put`s the staging above happens to make, and a hardcoded + // `toVersion` that drifts turns this door into a 404 that still looks + // like a refusal. + const target = historyRows().find((h) => { + const m = typeof h.metadata === 'string' ? JSON.parse(h.metadata) : h.metadata; + return m?.label === 'v1'; + }); + expect(target, 'fixture: history must carry a restorable `v1` row').toBeTruthy(); + const labelOfActive = () => { + const r = rows().find((x) => x.type === 'view' && x.state === 'active'); + return JSON.parse(typeof r.metadata === 'string' ? r.metadata : JSON.stringify(r.metadata)).label; + }; + return { protocol, rows, toVersion: target.version as number, labelOfActive }; + } + + it('CONTROL — the canonical spelling is refused by the lock', async () => { + const { protocol, toVersion } = await stageLockedActiveOverRestorableHistory({ locked: true }); + + const refusal = await refusalOf(() => (protocol as any).rollbackMetaItem({ + type: 'view', name: 'case_grid', organizationId: ORG, toVersion, + })); + + expect(refusal.code).toBe('ITEM_LOCKED'); + expect(refusal.status).toBe(403); + }); + + it('the plural spelling is refused by the SAME lock, and the protected body is unchanged', async () => { + const { protocol, rows, toVersion, labelOfActive } = + await stageLockedActiveOverRestorableHistory({ locked: true }); + + const refusal = await refusalOf(() => (protocol as any).rollbackMetaItem({ + type: 'views', name: 'case_grid', organizationId: ORG, toVersion, + })); + + expect(refusal.code).toBe('ITEM_LOCKED'); + expect(refusal.status).toBe(403); + // THE CLAUSE THAT MATTERS. The two assertions above can both hold while + // the write still lands; only the stored row says whether the refusal + // stopped the RESTORE. `v2` is the protected body — `v1` would mean the + // rollback went through and the lock was decorative. + expect(labelOfActive()).toBe('v2'); + // …and it did not mint a second namespace on the way (#4432). + expect(rows().some((r) => r.type === 'views')).toBe(false); + expect(rows().filter((r) => r.state === 'active').map((r) => r.type)).toEqual(['view']); + }); + + it('POSITIVE CONTROL — unlocked, the SAME plural call really does restore `v1`', async () => { + // Without this the group is green for the wrong reason: if the harness + // could not perform a rollback at all, "the protected body is unchanged" + // would hold trivially and the lock would be pinning nothing. This is + // the same call, same spelling, same version — only the lock removed. + const { protocol, toVersion, labelOfActive } = + await stageLockedActiveOverRestorableHistory({ locked: false }); + + const receipt: any = await (protocol as any).rollbackMetaItem({ + type: 'views', name: 'case_grid', organizationId: ORG, toVersion, + }); + + expect(receipt.success).toBe(true); + expect(receipt.restoredFromVersion).toBe(toVersion); + expect(labelOfActive()).toBe('v1'); + // The receipt names the CANONICAL type, not the caller's spelling: the + // fold reaches the receipt sentence too, so a compliance reader and the + // stored row agree on one spelling. + expect(receipt.message).toContain('type=view,'); + }); +}); From c1254a83aeb2ee63d9f2884b32895af8458c83f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:09:42 +0000 Subject: [PATCH 2/2] docs(objectql): record #8819's group D seam and its measured ablation in the pin header Includes the ablate-against-an-immovable-sha warning: origin/main advanced mid-run (shared .git across worktrees) and the first ablation attempt measured a newer protocol.ts against this worktree's older spec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .../protocol-publish-canonical-fold.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index baf2a1c09d..3205200a49 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -73,6 +73,42 @@ * and `expected a refusal, got success` (C — the locked publish went through). * Captured output is quoted in the PR body. * + * ── [#8819] Group D — the same lock, one verb over ───────────────────────── + * + * `rollbackMetaItem` was the EIGHTH `/meta` entry point and the last one still + * deriving its type key from `PLURAL_TO_SINGULAR`. Group C's seam exactly, on + * the verb that restores a body FROM HISTORY rather than promoting a draft — so + * what a missed lock costs there is the active body silently REVERTING, which is + * why the group carries its own "protected body unchanged" assertion instead of + * inheriting C's. + * + * The ablation marker moves with the call count for the same reason as above: + * `grep -o canonicalizeMetaRequestType dist/index.js | wc -l` is **9** with this + * fold (one definition + eight call sites) and **8** without it. + * + * ⚠️ Ablate against an IMMOVABLE SHA, not `origin/main`. Every worktree shares + * one `.git`, so `origin/main` advances under a running task: the first attempt + * here checked out a `protocol.ts` newer than this worktree's `spec` and died on + * `has no exported member 'unrecognisedMetaTypeRefusal'` — a contaminated + * measurement that could as easily have produced a plausible wrong number as a + * loud error. `git checkout -- ` is the form that measures + * what it claims to. + * + * with the fix without it (baseline fd6bdf89f) + * ---------------------------- ------------------------------------------ + * D canonical control 403 403, unchanged → GREEN + * D plural, locked 403 200, active body reverted to `v1` → RED + * D positive control 200 200 — the restore still works; red + * only on the receipt spelling → RED + * + * Predicted 2 red / 8 green; measured 2 red / 8 green, and — the part worth + * recording — the two reds are red for DIFFERENT reasons, both predicted before + * the run: `expected a refusal, got success` (the locked rollback went through) + * and `expected 'Reverted to version 1 — type=views, n…' to contain + * 'type=view,'` (the restore itself succeeded; only the receipt still named the + * caller's spelling). A single-reason prediction would have been wrong here even + * though the count would have matched. + * * ── Why a REAL engine and not an engine double ───────────────────────────── * * The thing under test is which type key the protocol derives before the