Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): revertCommit refuses a non-canonical stored type on its restore limb, with the wire code its sibling doors give (#9174) - #9360
Conversation
…ype on its restore limb (#9174) The third at-rest door joins the two that already name this class on the wire. Measured per limb at HEAD: the restore limb answered success with reverted[].action 'restored' while registerItem was never called and the only trace was a server-side console.warn from the write-through's best-effort catch; the soft-remove limb performs its promise exactly and is deliberately left outside the gate. Matches saveMetaItem's refusal shape on this door's existing per-item failed[] channel: code STORED_TYPE_NOT_CANONICAL, which already exists and is already in the error-code ledger for this package. No fold on any ledger key, no audit row, no new receipt surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…onst (#9174) The hoisted boolean broke TypeScript's narrowing on `it.prevVersion` — a MUTABLE property, so a narrowing does not survive an aliased boolean — and `repo.restoreVersion(ref, it.prevVersion, …)` stopped compiling with `TS2345: 'number | null' is not assignable to 'number'`. Caught by the DTS build while all 1651 package tests stayed green, because vitest transpiles without type-checking. `restoreToVersion` is the same single source of truth for both the pre-flight and the branch, and narrows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…restore left behind (#9174) Moving the spy restore into `afterEach` (so a failing assertion cannot leak the spy into the next test) left six `const warn = spyWarn()` bindings unread. tsc counted them (TS6133) and the shrink-only DEBT ratchet for this package went 63 -> 69. Back to 63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
os-zhuang
commented
Aug 17, 2026
PM review — the refuse/decline choice was the work, and it was made by measurement⛔ Not flipping yet; checking CI job-by-job. ⭐ The discriminator is the contributionI dispatched this saying the real work was choosing between refuse and decline-and-report, and that measuring what the caller can do with each answer had to come first. You produced a discriminator sharper than the one I gave you:
and then applied it three ways rather than asserting it. A receipt asserting the pre-commit body is what the platform now serves, for a body that reaches no reader at all. ⭐ The soft-remove exclusion is the part I would have got wrongLeft un-gated because it performs its promise exactly, and because refusing it would answer The discipline that made the rest trustworthy
Tier fence — held, including the tempting partNo new error code; Route
Holding for CI. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
…cers and their atomicity
The ledger entry at error-code-ledger.zod.ts:381 named only the publish
pre-flight as STORED_TYPE_NOT_CANONICAL's producer ("refused at the publish
pre-flight, batch-atomic"). PR #9360 (#9174) added a second producer -
revertCommit's restore limb, which refuses per-item on its existing
failed[] channel and is explicitly NOT batch-atomic - leaving the comment
naming one of two producers and the wrong atomicity for the one it omitted.
Names both now: the publish pre-flight (batch-atomic, #8908) and
revertCommit's restore limb (per-item on failed[], NOT batch-atomic,
#9174), verified against the merged #9360 diff at protocol.ts:14432 and
protocol.ts:16399.
Text-only change - accept/reject behavior, the error code, and its
envelope are all unchanged.
Fixes#9361
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP
Fixes#9174
revertCommitwas the third consumer of an at-resttypeand the only one without aSTORED_TYPE_NOT_CANONICALpre-flight. Its restore limb handed the raw stored spelling to the registry write-through, where #9111's mint door refuses it and the throw lands in the best-effortcatch— so an operator got a server-sideconsole.warnwhere both sibling doors give a named, wire-visible verdict.Premise re-verified at HEAD (
4906c900f), not inheritedStill open.
protocol.tsat HEAD carries #9111's own producer trace, which names this route:What was measured, per limb, before any shape was chosen
Driven end to end over the real
SysMetadataRepositoryon an unscoped kernel, with afields/showcase_task.titlerow at rest (the harness is nowprotocol.revert-stored-type-canonical.test.ts):RESTORE limb (
existedBefore: true,prevVersion: 1)The receipt claims the pre-commit body is what the platform now serves. For this class it cannot be: the mint door refuses the entry and boot refuses it too, so the restored body reaches no reader at all.
SOFT-REMOVE limb (
existedBefore: false)Nothing about that outcome is wrong.
The shape:
saveMetaItem's refusal, notmigrateStoredMetadata's declineMatched sibling:
saveMetaItem— a wire-visible coded refusal, carried on this door's existing per-itemfailed[]channel, the same oneVERSION_NOT_FOUND,ITEM_LOCKEDandNOT_OVERRIDABLEalready ride. No new receipt surface and no new error code.The test that separates the two siblings is whether the door can do what it promises for this row:
migrateStoredMetadataoutcome: 'skipped'(must not poisonstoredMigrationClean, which runs forever)saveMetaItem/publishPackageDraftsrevertCommitrestore limbrestoredpromisesSo the item is refused and
successgoes false: the commit the operator asked to undo was not undone. A one-shot operator action has no forever to poison, and answeringsuccess: trueover a deliberately untouched item is the class of lie #5980 and #6621 already paid for in this function.Also measured: what the caller can DO with each answer. With the refusal the caller gets the stored spelling, the canonical spelling, the
PUT /meta/field/…re-author path, and the note thatPOST /meta/_migrate-storedwill not fix it either — the operator does not bounce between two doors. With today'sconsole.warnthe caller gets a receipt saying the revert worked.The soft-remove limb is deliberately outside the gate
It performs its promise exactly and completely, and the removal is the one action that makes this residue smaller. Refusing it would answer
success: falsefor a revert that fully succeeded, and would hand back an instruction — "drop thefieldsrow" — naming the very operation it had just declined to perform. #8908's own predicate is scoped the same way and for the same reason: it excludes the manifest-PRESENT plurals because widening "would change a wire-visiblefailed[].codefor rows that are NOT this defect".This is a scope decision, not a softened gate, and ablation 3 is what keeps it honest.
No ledger key is touched
The refusal writes no audit row and no commit record, and carries
it.typeintofailed[]verbatim. #9161's ruling — the caller's spelling reaches the ledger keys unfolded soAUDIT_TYPE_NOT_CANONICALfires loudly — is untouched in both directions. A refused item is simply absent fromreverted[], so the append-only revert commit built from it never claims an undo that did not happen (pinned in §4).The publish pre-flight audits its refusals; this one does not, on purpose.
revertCommitwrites no audit row for any of its per-item failures, so a row minted only for this class would be a lone entry in a ledger the function otherwise never writes — and it would need the type folded to be readable, which is the one move the card rules out.Predicate: the narrow at-rest one
isNonCanonicalStoredType, not the completecanonicalMetaType(t) !== t.objects/viewsfold in the manifest map, so the restore limb already hands the write-through a canonical key and those rows revert and register correctly today. Ablation 4 pins that.Tier fence
No new error code.
STORED_TYPE_NOT_CANONICALalready exists and is already listed under@objectstack/metadata-protocolinpackages/spec/src/api/error-code-ledger.zod.ts:381.packages/specis untouched — no clause ②.Reviewer note, deliberately not acted on: that ledger line's trailing comment now names one of two producers ("refused at the publish pre-flight, batch-atomic"). Amending it means editing
packages/spec, which pulls in a different gate family than this diff has, so it is left for whoever next touches the ledger.Changed line ranges (
git diff --unified=0vsorigin/main) —protocol.tsis the hot fileOnly two of the six are executable-line changes to existing code; the two large hunks are the gate plus its reasoning, and the first two are comment corrections made necessary by the change.
Other files:
protocol.hydrate-overlay-canonical-type.test.ts(7 lines, route-5 header correction only — no assertion changed, its call-site count pin is untouched at 3), the new test file, and the changeset.Ablation legs, predicted before running
reverted[]and therefore into the revert commit. §2 and §3 stayed GREEN, which is the half that matters. Recorded as observed, not trimmed to the prediction. This leg is the pre-fix measurement quoted above.canonicalMetaType(t) !== tLeg 2 first ran red-but-unreadable on eight tests for a reason that had nothing to do with the ablation:
vi.spyOn(console, 'warn')was restored at the end of each test body, so the first failing assertion skipped the restore and the spy accumulated into every later test. The spy now restores fromafterEach. A leg that cannot be read is a leg that was not run, so this is recorded rather than quietly fixed.Verification — all at
c40b70024, the final commitGate union re-derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjsand run atc40b70024:check:nul-bytes,check:error-code-casing,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:cross-package-test-inputs,check:durability-log-level,check:filter-alias-parity,check:changeset-gate-self-tests,check:objectui-changeset,check:type-check-coverage,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,docs-audit/check-affected-docs— all PASS.Two defects in my own work were caught by running the pipeline rather than reading it, and both are recorded because each was invisible to one half of it:
TS2345atrestoreVersion. Hoisting the limb condition into a boolean broke TypeScript's narrowing onit.prevVersion, a mutable property whose narrowing does not survive an aliased boolean. All 1651 tests stayed green — vitest transpiles without type-checking — and only the DTS build saw it. Fixed by bindingrestoreToVersion: number | null, which is still one source of truth for both the pre-flight and the branch, and narrows.TS6133unusedwarnbindings left behind when the spy restore moved intoafterEach. Back to 63; the ratchet re-measures clean. NoTEST_DEBTentry was raised and no ledger number was touched.Generated by Claude Code