Skip to content

fix(metadata-protocol): put and delete accept the version a checksum-less sys_metadata row is served as - #21990

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21978-served-version-compare
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21978-served-version-compare

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21978
Clause-②: no

What this changes

SysMetadataRepository (packages/metadata-protocol/src/sys-metadata-repository.ts) served a sys_metadata row that has no checksum as the hash of its stored body (rowToItem), but put and delete judged 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. Every saveMetaItem / deleteMetaItem answered 409 METADATA_CONFLICT ("Expected parent hmac-sha256:… but current is null"), whether the parent was the version the door served or no If-Match was 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:

  • One helper, servedVersion(ref, row): the stored checksum, else hashSpec(body, type). rowToItem now reads it, so every read hands out this one value.
  • One lock, lockAccepts(ref, row, parent), used by put and delete. It accepts the row's stored stamp, which is the old compare unchanged: a row with a checksum is judged exactly as before, and a null parent still matches a checksum-less row. For a checksum-less row it also accepts the served version.
  • The conflict's head (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 keeps null there, so a lock refusal never becomes a parse error.
  • The lineage fields (previous_checksum, the event's parentHash) 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 in protocol-publish-drafts-package-scope.test.ts that this change made false. Changeset: @objectstack/metadata-protocol patch.

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 no checksum:

  • (a) saved and deleted with the version its read serves, in the keyed form a door hands out: the repository's own get read, keyed;
  • (a) unpinned (last-write-wins) save and delete succeed: the dogfood shape;
  • (b) a stale keyed token and the raw served hash are still refused with METADATA_CONFLICT / 409 on both doors; actualHead is the served token, the row is untouched, and retrying with that actualHead succeeds;
  • (c) a null parent still succeeds: storedParentVersion: row.checksum ?? null, the stored-row migration's in-process spelling;
  • (d) after each write the row carries hashSpec(newBody, 'view'); an identical re-save stamps it too;
  • publish over a checksum-less active row; the drain removes a checksum-less draft row;
  • repository level: a row WITH a checksum whose stamp differs from its body's hash refuses the body's hash and null (both name the stamp as head) and accepts its stamp; a checksum-less row accepts null and 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.mjs with an EXIT/INT/TERM restore trap and absolute paths. It restored the raw compare in both put and delete (anchor hit x2 → x0, replacement x0 → x2, blob dc58518587 → 494fa3f0ee; on disk, raw-compare 0 → 2 and lockAccepts call 2 → 0).

  • Predicted beforehand: 7 of the 9 new pins red, and green for the null-parent pin and the stamped-row pin, which guard against widening and against narrowing rather than this mutation.
  • Observed: 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 read actualHead null.
  • Restore was proven by observation: blob after restore dc58518587 equals the HEAD blob, git diff HEAD is empty, and git status --porcelain is empty.
  • An earlier invocation was a no-op: the tool refused with exit 2 before writing, because it located the repository from the shared checkout's cwd. On-disk counts were unchanged, and it was rerun from the worktree root.

The subject is imported by relative src path (./protocol.js, ./sys-metadata-repository.js), so no dist/ sits on the ablation's resolution path.

Clause-② (measured against the built entry declarations)

packages/metadata-protocol/dist/index.d.ts was built at HEAD, and again with BASE 8a399b2b15's repository source swapped in behind a trap. The swap was restored and proven by blob equality, and HEAD was rebuilt, giving a byte-identical index.d.ts. The diff's non-comment lines are private servedVersion;, private lockHead; and private lockAccepts;, with 0 removed; everything else is doc text. index.d.cts has the identical diff. No exported type or signature moves. Behaviourally, put / delete accept 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 merging origin/main twice, the second bringing PR #21979's protocol.ts change)

  • pnpm --filter @objectstack/metadata-protocol test: Test Files 218 passed | 3 skipped (221), Tests 28028 passed | 19 skipped (28047). typecheck: tsc --noEmit clean, and the test file is in the program (--listFiles count 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-debt ran 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-loads and check:lean-entry-closure ran after a full turbo run build (72 tasks, 71 cached). Reconciliation, --ran with per-command exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN".
  • The artifact-roster block (55 families, outside the total): 52 at exit 0. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths answered NOT WIRED (exit 2, no PR context); they are rerun against this PR and reported in the os-dev-report comment.
  • The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors): exit 0.
  • NOT MEASURED locally, owned by CI: the five path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and the workspace type-check lanes. packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts was not run locally.

Census: writers of sys_metadata that can store a row with no checksum

Writer Where checksum Still producing such rows
SysMetadataRepository.put (insert / update) metadata-protocol/src/sys-metadata-repository.ts always hashSpec(body, type) no
SysMetadataRepository.delete same file removes the row. Its tombstone goes to sys_metadata_history with checksum: null by design n/a (history table)
datasource admin door writeDatasourceRow service-datasource/src/datasource-admin-plugin.ts hashSpec(record, 'datasource') since PR #21977; none before no. Its pre-#21977 rows are the stored population this PR makes writable
datasource admin door delete fallback same file update { state: 'inactive' }, which keeps the column no
DatabaseLoader save / create / registerRollback metadata/src/loaders/database-loader.ts contentHash stamp no
protocol orphan adoption (package_id rebind) metadata-protocol/src/protocol.ts partial update, which keeps the column no
protocol legacy delete, permission-set overlay discard protocol.ts, plugin-security/src/permission-set-overlay-discard.ts delete only no
env_id → project_id migration metadata/src/migrations/migrate-env-id-to-project-id.ts column rename DDL no
stored-row migration, flow credential move protocol.ts migrateStoredMetadata, service-automation/src/flow-credential-migration.ts through saveMetaItem → put (stamps) no. Both were refused on such rows before this PR and succeed now
generic data door, MCP data bridge, flow write nodes, hook bodies — refused: sys_metadata declares apiMethods: ['get', 'list'], plus the stored-metadata family refusals no

A tombstone reads back as a delete event with hash: null (history() / rowToEvent). getByHash never matches it, and restoreVersion refuses it with VERSION_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 explicit parentVersion: null by saying a raw-seeded row's derived parent "would 409". After this change it would not; the null it passes stays valid. Comment drift in another package, left as is. Owner: none.
  • The first write over a checksum-less row records 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.
  • A conflict-audit note on such a row now reads "current is (withheld)" where it read "current is null", because the head is no longer null.
  • DraftDrainFailure.draftHash is documented as "the row's checksum". It is the served version, the same value for a stamped row. This is a doc imprecision predating this PR.
  • Rollback (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.
  • No door read serves a version token for a stored row that has no history; the tokens come from receipts, history events and a 409's 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

claude added 4 commits October 6, 2026 11:39
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 6 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via SysMetadataRepository (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1c563af40e21248d1e0be2f13cc47626aaad893e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a482822fc821d01054463ef041a7173c013a3373 — the merge of head 81606021e2ee823cc4bc55084b68bc7562b21c91 into base 1c563af40e21248d1e0be2f13cc47626aaad893e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1c563af40e21248d1e0be2f13cc47626aaad893e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21990 at head 81606021e2

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-06T13:27Z. The dev's report is os-dev-report on #21978.

  • The change follows triage's direction (6014717866). The seat read the helpers in sys-metadata-repository.ts:
    • One served version, servedVersion (checksum ?? hashSpec(body, type)), which rowToItem reads.
    • One lock, lockAccepts, used by put and delete. Its first arm is the base compare, byte for byte, so a stamped row is judged exactly as before. Only a checksum-less row also accepts its served version, and a null parent still matches it.
    • A conflict names the served head (lockHead), not null.
    • The lineage fields and the no-op check keep the raw stamp, so the next write stamps the row.
    • There is no backfill and no migration.
  • Other callers repaired by the same lock (H1, measured): unpinned save and delete; publish over a checksum-less active row; rollback and revert (read in code); and the post-promotion drain, which silently left a checksum-less draft pending.
  • Census (H3): no writer in the tree still stores a checksum-less sys_metadata row after PR fix(service-datasource): the admin door reads a datasource's origin from provenance, and a metadata-door write reaches it in the same boot #21977. No follow-up card is owed.
  • Pins: 9 cases in protocol.served-content-hash.test.ts, reusing its engine double.
    • Reverse verification restored the raw compare and turned exactly the 7 predicted cases red, with the base's text: "Expected parent hmac-sha256:… but current is null". It was then restored by blob equality.
  • CI on 81606021e2: 31 success, and the 3 skips are in the roster.
  • Readings against main at aa09db58c9: NOT governed (0 of 4 paths), and git merge-tree is clean.
  • Clause-②: no, at patch. Only three private members were added to the built declarations. No contract review is owed.
  • Noted, not filed (both are in the PR's Acceptance notes):
    • a packages/cli integration test's comment, which says a raw-seeded row's derived parent "would 409";
    • DraftDrainFailure.draftHash's docblock wording.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants