Skip to content

fix(metadata-protocol): route rollbackMetaItem through the canonical type fold, closing a plural-addressable ADR-0010 lock (#8819) - #8870

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-8819-rollback-canonical-fold
Aug 15, 2026
Merged

fix(metadata-protocol): route rollbackMetaItem through the canonical type fold, closing a plural-addressable ADR-0010 lock (#8819)#8870
hotlong merged 3 commits into
mainfrom
claude/issue-8819-rollback-canonical-fold

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#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. Seven siblings fold; this one did not.

The half that was not fail-closed

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 ADR-0010 _lock up under a type no row carries, got 'none' back — 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.

Severity, at its real width and not rounded up. It needs an environment kernel (assertLockAllowsWrite opens with if (this.environmentId === undefined) return null) 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. My measurement agrees with the card's stated window exactly; I did not find it wider or narrower.

The fold also reaches three limbs that were incoherent rather than unsafe: the revertability tier 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.

Placement

After the existing toVersion envelope guard, not at the 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.

Scope — what this deliberately does NOT do

getEffectiveLock's overlay limb still queries the raw type. Folding it there would close this class at the producer for every present and future caller, and that is the contract-first shape — but it is a shared gate whose blast radius nobody has measured, so per the triage ruling it stays open for its own card with its own measurement. It is not ridden in here and the per-verb fix does not foreclose it; the code comment says so at the site, so a later reader cannot mistake this for the class being closed. The per-verb fix needed no change to the shared gate to be correct, so there was no fork to stop on.

Pin — protocol-publish-canonical-fold.test.ts group D

The shape #8769 used for group C, driving the real ObjectQL / protocol / SysMetadataRepository over an in-memory driver on an environment kernel (an environment-less kernel skips the gate wholesale, and a harness that cannot reach the gate cannot pin it):

  • CONTROL — the canonical spelling is refused by the lock (ITEM_LOCKED / 403, the ADR-0112 envelope, never "it threw");
  • the plural spelling is refused by the same lock, and the protected active body is unchanged — the clause that matters, since the first two can both pass while the write still lands;
  • POSITIVE CONTROL — the identical plural call with the lock removed really does restore the earlier body. Without this the group would be 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 verb needed its own group rather than inheriting group C's: a rollback writes a new active row from history instead of promoting a draft, so what a missed lock costs here is the active body silently reverting.

Reverse verification — direction predicted BEFORE running, and one prediction was two-sided

Ablated against the immovable baseline fd6bdf89f and rebuilt metadata-protocol, because packages/objectql resolves it through dist. Mutation proven to reach the artifact by the executable marker this file already uses: grep -o canonicalizeMetaRequestType dist/index.js | wc -l is 9 with the fold (one definition + eight call sites) and 8 without.

Predicted 2 red / 8 green; measured 2 red / 8 green — and the part worth recording is that the two reds are red for different reasons, both predicted:

FAIL the plural spelling is refused by the SAME lock, and the protected body is unchanged
Error: expected a refusal, got success
FAIL POSITIVE CONTROL - unlocked, the SAME plural call really does restore `v1`
AssertionError: expected 'Reverted to version 1 - type=views, n...' to contain 'type=view,'

The first is the defect itself: the locked rollback went through. The second is not a broken restore — the restore still succeeded, and 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.

⚠️ A trap worth inheriting: the first ablation attempt used git checkout origin/main -- protocol.ts, and origin/main advanced mid-run (every worktree shares one .git). That pulled 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. Ablate against an immovable sha. This is recorded in the pin file's header.

Verification — all at c1254a83a, the final commit

Merged origin/main during the run (#8770 and #8854 landed while working, as dispatch predicted); the diff vs merge-base is three files and stays inside the declared region.

Per-package suites, real numbers:

packageresult
@objectstack/metadata-protocol101 files / 1483 tests passed
@objectstack/objectql210 files / 3675 tests passed
@objectstack/rest118 files / 1948 tests passed
@objectstack/runtime163 files / 2440 tests passed

typecheck green for all four. Gate union, re-derived from the actual changed paths with scripts/pm/dispatch-gates.mjs and run after the final commit:

  • dispatch-named: check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check-cross-package-test-inputs.mjs, check-engine-split-ratio.mjs, check:query-options-erasure, check:type-check-coverage, check:type-check-debt --re-measure — all PASS;
  • five the dispatch list could not have named, because they key on the changeset added after dispatch: check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs — all PASS;
  • plus check:nul-bytes (any edit) — PASS.

The TEST_DEBT ratchet was measured on a fully built workspace, not inferred from a green typecheck: 33 ledger entries re-measured in 286.2s, 1926 raw tsc errors total, none above its recorded number. Its reported surplus is in @objectstack/lint, a package this PR never touches, and is already tracked by #6376.

Out-of-scope finding

Filed #8868 (unassigned, for triage), not fixed here: diffMetaItem is a ninth entry point on this URL family with the same unfolded manifest-map lookup, and for the four manifest-absent types it answers a well-formed empty diff instead of the real one. It is read-only — no lock, no write — so none of this card's severity carries over. That issue is not addressed by this PR.


Generated by Claude Code

…type fold (#8819)
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 12:27pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rollback-verb-skips-the-canonical-fold: rollbackMetaItem addresses around the ADR-0010 overlay _lock when spelled with a plural

2 participants

@hotlong@claude