fix(metadata-protocol): put and delete accept the version a checksum-less sys_metadata row is served as - #21990
Conversation
…less row is served as SysMetadataRepository served a row with no `checksum` as the hash of its stored body (`rowToItem`), but `put` and `delete` judged the caller's parent against the raw column (`null`). Such a row could never be written or removed through the metadata door: every save and delete, an unpinned one included, answered 409 METADATA_CONFLICT. One helper (`servedVersion`) now names the version a row is served as, and the lock (`lockAccepts`) accepts it as the head of a checksum-less row. A row with a `checksum` is judged exactly as before, a `null` parent still matches a checksum-less row, and the next write stamps the row. A conflict on such a row reports its served version. No stored row is rewritten. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…o checksum A checksum-less `sys_metadata` row is saved, deleted and published over through the version its read serves (with and without a pinned parent), a stale version is still refused with 409 METADATA_CONFLICT naming the served version, a null parent still matches it, the write stamps it, the post-promotion drain removes such a draft, and a row with a checksum is judged exactly as before. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…rved-version-compare
…rved-version-compare
📓 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:
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 a482822fc821d01054463ef041a7173c013a3373 && git checkout a482822fc821d01054463ef041a7173c013a3373
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c563af40e21248d1e0be2f13cc47626aaad893e 81606021e2ee823cc4bc55084b68bc7562b21c91 && git checkout -B drift-repro 1c563af40e21248d1e0be2f13cc47626aaad893e && git merge --no-ff 81606021e2ee823cc4bc55084b68bc7562b21c91
node scripts/docs-audit/affected-docs.mjs --json 1c563af40e21248d1e0be2f13cc47626aaad893e
|
ACCEPT (seat review) — PR #21990 at head
|
Fixes #21978
Clause-②: no
What this changes
SysMetadataRepository(packages/metadata-protocol/src/sys-metadata-repository.ts) served asys_metadatarow that has nochecksumas the hash of its stored body (rowToItem), butputanddeletejudged the caller's parent against the raw column (existing.checksum ?? null). So a row like that could never be written or removed through the metadata door. EverysaveMetaItem/deleteMetaItemanswered409 METADATA_CONFLICT("Expected parent hmac-sha256:… but current is null"), whether the parent was the version the door served or noIf-Matchwas sent at all, because the door takes the parent from the same read. Publish, rollback and commit revert over such a row hit the same lock, and the post-promotion drain of a checksum-less draft was refused and silenced as a benign race.Per triage's direction (6014717866), with nothing narrowed and no backfill:
servedVersion(ref, row): the storedchecksum, elsehashSpec(body, type).rowToItemnow reads it, so every read hands out this one value.lockAccepts(ref, row, parent), used byputanddelete. It accepts the row's stored stamp, which is the old compare unchanged: a row with achecksumis judged exactly as before, and anullparent still matches a checksum-less row. For a checksum-less row it also accepts the served version.lockHead) is the served version, so a 409 on such a row names the version a read hands out (before this,null). A checksum-less row whose bytes do not parse keepsnullthere, so a lock refusal never becomes a parse error.previous_checksum, the event'sparentHash) and the no-op check keep reading the raw stamp. So the first write over a checksum-less row, even with an identical body, stamps the row as usual. Nothing is rewritten at rest, and the header's "no backfill" non-goal stands, now with one line on how such a row is served.File surface: as dispatched. The producer that wrote such rows (the datasource admin door) already stamps a checksum since PR #21977, which is on
main, so the remaining work is the stored rows, and that lands in this repository class. Two test files in the same package: the pins, plus one fixture comment inprotocol-publish-drafts-package-scope.test.tsthat this change made false. Changeset:@objectstack/metadata-protocolpatch.Pins (
protocol.served-content-hash.test.ts, the existing conflict-test double)Through the protocol's real
saveMetaItem/deleteMetaItem/publishMetaItem, on a row seeded with nochecksum:getread, keyed;METADATA_CONFLICT/409on both doors;actualHeadis the served token, the row is untouched, and retrying with thatactualHeadsucceeds;nullparent still succeeds:storedParentVersion: row.checksum ?? null, the stored-row migration's in-process spelling;hashSpec(newBody, 'view'); an identical re-save stamps it too;null(both name the stamp as head) and accepts its stamp; a checksum-less row acceptsnulland its served version, and refuses anything else with the served version as head.Reverse verification (committed HEAD
5c4815a6ab)The mutation went through
scripts/ablation-replace.mjswith an EXIT/INT/TERM restore trap and absolute paths. It restored the raw compare in bothputanddelete(anchor hit x2 → x0, replacement x0 → x2, blobdc58518587→494fa3f0ee; on disk, raw-compare 0 → 2 andlockAcceptscall 2 → 0).null-parent pin and the stamped-row pin, which guard against widening and against narrowing rather than this mutation.Tests 7 failed | 16 passed (23), the 7 predicted. The save door reproduced the card's text verbatim: "view/case_grid has been modified since you loaded it. Expected parent hmac-sha256:e532d121… but current is null." The drain pin read the draft row still present, and the repository pin readactualHeadnull.dc58518587equals the HEAD blob,git diff HEADis empty, andgit status --porcelainis empty.The subject is imported by relative
srcpath (./protocol.js,./sys-metadata-repository.js), so nodist/sits on the ablation's resolution path.Clause-② (measured against the built entry declarations)
packages/metadata-protocol/dist/index.d.tswas built at HEAD, and again with BASE8a399b2b15's repository source swapped in behind a trap. The swap was restored and proven by blob equality, and HEAD was rebuilt, giving a byte-identicalindex.d.ts. The diff's non-comment lines areprivate servedVersion;,private lockHead;andprivate lockAccepts;, with 0 removed; everything else is doc text.index.d.ctshas the identical diff. No exported type or signature moves. Behaviourally,put/deleteaccept for a checksum-less row the version the same repository already serves for it, which is the declared version token, not a new class of input.Tests and gates: all on HEAD
81606021e2(after mergingorigin/maintwice, the second bringing PR #21979'sprotocol.tschange)pnpm --filter @objectstack/metadata-protocol test:Test Files 218 passed | 3 skipped (221),Tests 28028 passed | 19 skipped (28047).typecheck:tsc --noEmitclean, and the test file is in the program (--listFilescount 1). Lock VERDICT command-exit 0.node scripts/pm/dispatch-gates.mjs --commands(no paths) derived the 63 commands, and all ran at exit 0.check:type-check-debtran under the verify lock ("1 ledger entr(ies) re-measured … 26 raw tsc error(s) total, none above its recorded number").check:dual-build-cjs-loadsandcheck:lean-entry-closureran after a fullturbo run build(72 tasks, 71 cached). Reconciliation,--ranwith per-command exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN".check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsanswered NOT WIRED (exit 2, no PR context); they are rerun against this PR and reported in theos-dev-reportcomment.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchors,check:adr-anchors): exit 0.packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.tswas not run locally.Census: writers of
sys_metadatathat can store a row with nochecksumchecksumSysMetadataRepository.put(insert / update)metadata-protocol/src/sys-metadata-repository.tshashSpec(body, type)SysMetadataRepository.deletesys_metadata_historywithchecksum: nullby designwriteDatasourceRowservice-datasource/src/datasource-admin-plugin.tshashSpec(record, 'datasource')since PR #21977; none beforeupdate { state: 'inactive' }, which keeps the columnDatabaseLoadersave / create /registerRollbackmetadata/src/loaders/database-loader.tscontentHashstamppackage_idrebind)metadata-protocol/src/protocol.tsprotocol.ts,plugin-security/src/permission-set-overlay-discard.tsenv_id→project_idmigrationmetadata/src/migrations/migrate-env-id-to-project-id.tsprotocol.tsmigrateStoredMetadata,service-automation/src/flow-credential-migration.tssaveMetaItem→put(stamps)sys_metadatadeclaresapiMethods: ['get', 'list'], plus the stored-metadata family refusalsA tombstone reads back as a
deleteevent withhash: null(history()/rowToEvent).getByHashnever matches it, andrestoreVersionrefuses it withVERSION_NOT_RESTORABLE. No writer is still live after this change, so no follow-up card.Acceptance notes
packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts(about:190) explains its explicitparentVersion: nullby saying a raw-seeded row's derived parent "would 409". After this change it would not; thenullit passes stays valid. Comment drift in another package, left as is. Owner: none.previous_checksum: null/parentHash: null, the raw stamp. That is deliberate: no history row carries the served hash, so naming it would be a parent link to nothing.DraftDrainFailure.draftHashis documented as "the row'schecksum". It is the served version, the same value for a stamped row. This is a doc imprecision predating this PR.restoreVersion) and commit revert over a checksum-less active row take the served parent and pass the same lock. This was read in code; only publish is pinned as the representative internal caller.actualHead. So for a legacy row, the 409 is the first place a client sees its token. The stale-version pin covers that retry.Generated by Claude Code