Repository navigation
fix(metadata-protocol)!: rollbackMetaItem and revertCommit ask the save door's refusal family before they write a history body back - #25312
Conversation
…s refusal family (measurement, before the fix) Claude-Session: https://claude.ai/code/session_01HZVZ7xrs691S9potR9mSL1 Co-authored-by: Claude <noreply@anthropic.com>
…l family before they write a history body back Claude-Session: https://claude.ai/code/session_01HZVZ7xrs691S9potR9mSL1 Co-authored-by: Claude <noreply@anthropic.com>
…nel's held paths before it strips Claude-Session: https://claude.ai/code/session_01HZVZ7xrs691S9potR9mSL1 Co-authored-by: Claude <noreply@anthropic.com>
…he pinned ledger Claude-Session: https://claude.ai/code/session_01HZVZ7xrs691S9potR9mSL1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 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 see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 123029be96651c38b8c7aacfc6ae2d623c667a66 && git checkout 123029be96651c38b8c7aacfc6ae2d623c667a66
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 258f4e6aaa5c1f75675b4195a35cb5da2ce5e275 e5daf940f552900c0c35eeedd2b16b35af9a19a6 && git checkout -B drift-repro 258f4e6aaa5c1f75675b4195a35cb5da2ce5e275 && git merge --no-ff e5daf940f552900c0c35eeedd2b16b35af9a19a6
node scripts/docs-audit/affected-docs.mjs --json 258f4e6aaa5c1f75675b4195a35cb5da2ce5e275
|
Contract reviewServed-tier: Inputs: card #25278 (body, claim 6352974944, os-dev-report 6354044792), PR #25312 (body, 6 files, net diff against ① Derived judgmentsAccept set — what a restore writes narrows (the card's ask). Census, checked judge by judge against the save door at this head (
Public surface. Engine-double ledger. ② Semver level
Clause-②: no (narrowing) — read on the PR body and the changeset, consistent with the diff: no widening anywhere; a restore refuses what it accepted. ③ Boundary flagsDev deviations (os-dev-report 6354044792), each answered:
Open question (Zod parse on a restored body): A, escalated for a ruling, not decided here. The card names the family as the refusals "after the Zod gate", so leaving the parse unasked is inside the card; whether today's parse may refuse a stored version (and, the same question, whether a fallback-stored flow is judged in its stored or its converted spelling, ① item 5) is the ADR-0087 stored-shape question. The adopting seat should file it as its own card; nothing in this PR forecloses either answer. Dev out-of-scope findings, judged:
Nothing in the diff touches the datasource-admin service (#25277), the stored-shape detector (#25279) or Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
|
Requested by Bill · project thread
Fixes #25278
Clause-②: no (narrowing)
Metadata-protocol: narrowed
The two restore doors (
rollbackMetaItem,POST /api/v1/meta/TYPE/NAME/rollback; andrevertCommit's restore limb, whichrollbackToPackageCommitalso uses) write a history body back as the active row throughrepo.restoreVersion. Before this PR they asked only the save door'snamejudge and permission-set owner judge (restoredBodyWriter). They asked none of the refusalssaveMetaItemruns after its schema gate. A version saved before a refusal existed, or one half of a pair whose other half moved after the version was saved, came back live.Census (base
5801a4f2c)The refusals
saveMetaItemruns after its Zod gate (protocol.ts:23986), in its order, and whether each restore door asked them:rollbackMetaItembeforerevertCommitbeforebody:24094·runtimeHookWithoutBodyRefusal(:1000):24107·defaultRecordPageSaveRefusal(:22429)restored)externalbinding on a managed datasource, both halves:24118·externalBindingSaveRefusal(:22542)runAs: 'system'flow an anonymous endpoint names:24129·anonymousElevatedFlowRefusal(:22658):24146·assertRuntimeAuthoringRules(:7372)active, the row's own package, the credential channel's held paths:24218·runAuthoringGate(:7309):24348·SysMetadataRepository.readOnlyBaseCreateErrorstorerefusal (no crypto provider):24465·MetadataCredentialChannel.storeAlready asked by both restore doors before this PR, through
restoredBodyWriter(:7291), and unchanged:savedItemNameRefusal(save door:23813) andpermissionSetOwnerBindingRefusal(save door:23824). The Zod gate itself (:23986) is not in the family and is still not asked; reason below.Refusals a restore does not ask, with the reason (also written in the code)
putkeeps.SysMetadataRepository.put's package-door note records why D1 is not extended to the doors that carry a row's own binding: it would refuse the repair of legacy package-bound rows.store. A restore stores no credential. The channel keeps its current one (R2,restoredBodyDerivation), so there is nothing forstoreto refuse.Fix
refuseRestoredBodyTheSaveDoorRefuses(new,protocol.ts) asks refusals 1–6 through the same functions the save door calls, in the save door's order, with their envelopes. ⛔ No rule is copied.restoredBodyWritercalls it after the name and owner judges and before the R2 strip. It is now async and takes the restoring door'ssource.SysMetadataRepository.restoreVersionawaitsderiveRestoredBody(its type now allows a promise). It is still asked on the history body before the active row is read and beforeput, so a refused version writes nothing.rollbackMetaItemrethrows the refusal.revertCommitreports it per item infailed[]with its code, as it does for its other per-item refusals, and reverts the commit's other items.defaultRecordPageSaveRefusal's verb union gains'restored', so its sentence says "cannot be restored as the default record page". Three docblocks that listed the doors asking a refusal now name the restore doors.The pair rule, both halves. A datasource restored to managed (or with no
schemaMode) while objects bind itexternalis refused atschemaMode, naming each object. An object restored withexternalon a datasource that is managed now is refused atexternal. Both come fromexternalBindingSaveRefusal, which reads the stored side as it stands when the restore runs.Measured
packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts(new, 28 tests): refusals 1–6 (with both halves of #3), each throughrollbackMetaItemand throughrevertCommit, plus a CONTROL per door that restores an acceptable version of the same item. A refusal pin asserts the code and status, one named subject (the holder page, the bound object, theissues[].path), and that rows, history and registry are byte-identical afterwards.d0cbd909(test only, on5801a4f2c)expected null to be an instance of Error), revert returnedsuccess: true2cafc3712cafc371with therefuseRestoredBodyTheSaveDoorRefusescall deleted (scripts/ablation-replace.mjs --delete: anchor 1 → 0, blob3d4430ce→b2a9c352, restored to the HEAD blob,git diff HEADempty)Tests and gates
All on
2cafc371;e5daf940adds only the engine-double ledger rows. The test file resolves./protocol.jsfrom source, so the ablation needed nodist/leg.@objectstack/metadata-protocol:tsc --noEmitexit 0. Full suite one5daf940: 259 files passed (3 skipped), 20,943 tests passed, 19 skipped. That run includes one pin updated on purpose (next bullet); before that update it was the only failure.protocol.credential-channel.test.ts(R2 rollback) pinned the channel's calls during a rollback as['strip']. A rollback now first readsheldPaths:active, so the runtime authoring rules see a credential the channel holds as present (the draft promotion does the same). The pin now reads['heldPaths:active', 'strip']. What it guards is unchanged: the restore never callsstore, the channel keeps its current secret, and nothing goes back at rest.metadata-protocol:@objectstack/objectql465 files / 8,691 tests passed (it holds the real-repository rollback and revert suites).@objectstack/rest321 files / 6,607 passed, 344 skipped.dispatch-gates.mjs --commands): 77 families, every one exit 0.--ranreconciles 77 run, 0 NOT-MEASURED. 68 ran on2cafc371and the 9 the ledger file adds ran one5daf940. Three were re-run one5daf940:check:engine-double-contractwas red on the new test file's double.--writerecorded 3 rows inscripts/engine-double-contract.pinned.json.check-plugin-teardown-shape --self-testandcheck:dual-build-cjs-loadsexited 3 on the shallow checkout and went green after deepening.The ledger file is outside the claim's declared surface; it is the gate's mechanical consequence of the new double.
!): run on the final head after this PR opens; its VERDICT line is reported in theos-dev-reporton metadata-protocol: rollbackMetaItem / revertCommit restore a version without the save-door refusal family, so a restore can re-create a refused shape #25278.Acceptance notes
revertCommit. Each item is judged against the store as that item is reached, which is the save door's own judgement. Reverting a commit whose forward order was legal visits the same states in reverse, so a pair rule that held going forward holds going back.packages/rest/src/meta-item-history-routes.ts(the rollback route) says rollback "runs no content gate at all". After this PR that sentence is false. The file is outside this claim's surface except for an envelope change, which was not needed (the route already forwards a thrown 4xx throughhandleRouteError), so it is left for the seat.scripts/engine-double-contract.pinned.jsongains the new test file's three engine-double rows (check:engine-double-contract --write).Open question
Should a restore run the schema parse on the history body? Today a version that fails the current parse restores, and the save door would refuse it with
422 INVALID_METADATA. This PR leaves that alone: it is the ADR-0087 stored-shape question (stored rows keep their conversion path) and needs a ruling, not a door edit.Generated by Claude Code