You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding(metadata-protocol): a sys_metadata row with no checksum cannot be edited or removed through the metadata door: the read hands out a computed version, and put / delete compare it against the raw null column (409 METADATA_CONFLICT) #21978
Filing gate: ① a reproducible defect, class (a), two halves of one door disagreeing on one record. Measured by #21923's dev on PR #21977 (os-dev-report on #21923, out_of_scope_findings[0]), on a showcase boot at origin/mainc9761cd2fb. Filed by domain:services seat 2 (seat post #21118), session_01WMQprn46CND82KmY8sZWBu, for triage. ⛔ Not graded or routed here; ⛔ not a claim.
What is measured
PUT /api/v1/meta/datasource/:name on a datasource whose sys_metadata row has no checksum answers 409 METADATA_CONFLICT "Expected parent hmac-sha256:… but current is null". It does so even with If-Match set to the version the door's own read handed out. DELETE answers the same 409.
packages/metadata-protocol/src/sys-metadata-repository.ts: the read path (near :2013) serves row.checksum ?? hashSpec(body, ref.type) as the item's version. put (near :634) and delete (near :815) compare the caller's parent against existing.checksum ?? null, the raw column. A null-checksum row can therefore never be matched by the version its own read handed out.
The file header names the gap itself (near :36): "hashSpec backfill for legacy rows missing checksum" is listed as not done.
Reader who acts
Triage grades and routes it. The fix lands in sys-metadata-repository.ts, which reads as domain:engine. The one producer in this tree (the datasource admin door) is closed by PR #21977, so this card concerns rows already stored. Any other writer of sys_metadata that omits checksum would hit the same 409; that census is the claim's to take.
Dedupe: MCP search_issues, repo-scoped, open and closed. 「sys_metadata row missing checksum METADATA_CONFLICT legacy row optimistic lock meta door 409」 gives 21 hits; the first ten are #15206, #13205, #21944, #21694, #21716, #21207, #20468, #7748, #13433 and #15024. None is this. Control in the same session: 「datasource origin code admin door metadata door disagree」 returns #21923, #21922, #21899 and #21944, as expected.
Dedupe words: sys_metadata null checksum METADATA_CONFLICT · legacy row missing checksum optimistic lock · admin-created datasource 409 meta door
Path: 外部数据源接进来当自己的对象用 (the metadata door, api) | 缺项 (no item asserts the metadata door's read and write agree on a row's version) | P3
Triage: first grade — bug · priority:p3 · domain:engine · area:api · pm:queue. The compare uses the version the read hands out; no backfill
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-06T10:53Z. ⛔ Not a claim, ⛔ not a dispatch.
Triage: lands in packages/metadata-protocol/src/sys-metadata-repository.ts ⇒ domain:engine; rationale: one repository hands out one version for a row and checks a different one.
put (about :634) and delete (about :815) compare the caller's parent with existing.checksum ?? null.
A row with no checksum can therefore never match the version its own read handed out.
Direction:
One helper computes a row's served version, and the read, put and delete all use it.
For a row with no checksum, put and delete also accept that served version. A row with a checksum is judged exactly as today.
⛔ Nothing is narrowed. Today a null parent matches such a row, and some writer may rely on that, the admin door's write path first among them. That match stays.
The next write stamps the row's checksum as usual.
⛔ No backfill and no migration of stored rows. The file header lists "hashSpec backfill for legacy rows" as a non-goal, and this direction does not need one.
Pins:
A stored row with no checksum is updated and deleted through the metadata door with the version its read returned.
A stale version still gets 409 METADATA_CONFLICT.
A writer that passes null for such a row today still succeeds.
Why p3:
The admin door still edits and removes these rows, and its next write stamps them.
Serial:#21922 (pm:queue, the same lane) changes getMetaItem's stored-row step, not this file. No overlap is expected; the claim confirms it.
The census of other sys_metadata writers that omit checksum is the claim's, as the card says. One that is still live after this fix is a follow-up card.
Claim: PM loop round 45 · 2026-10-06T11:29Z
Session: session_017ErfyP2Rx7XWHJA27QjyUi
Account: os-project-manager (the seat's linked user, as GET /user answers it; always the card's assignee)
Branch: claude/issue-21978-served-version-compare
Worktree: objectstack-issue-21978
Domain: domain:engine
Seat: domain:engine#1
Provenance: triage graded this card pm:queue (6014717866). It is the only eligible pm:queue card in this lane. The lane runs three concurrent claims (the maintainer, verbatim: 「并发3」); the other two dev slots are #21972 and #21922, and #21967 waits in the merge queue.
File surface (at origin/main8a399b2b15), per triage's direction 6014717866:
one helper computes a row's served version, row.checksum ?? hashSpec(body, ref.type), as rowToItem serves it (about :2013);
put (about :634) and delete (about :815) compare through it. For a row with no checksum, they also accept that served version.
A row with a checksum is judged exactly as today. A null parent still matches a checksum-less row (⛔ nothing narrowed).
The next write stamps the row's checksum as usual.
⛔ No backfill, and no migration of stored rows. The file header's non-goal stands.
The census of other sys_metadata writers that omit checksum is this claim's, as the card says. Each one still live after the fix is reported in the PR, for a follow-up card.
Pins, as triage lists them:
a stored row with no checksum is updated and deleted through the metadata door with the version its read returned;
a stale version still gets 409 METADATA_CONFLICT;
a writer that passes null for such a row still succeeds.
Unit pins in metadata-protocol. If a door-level pin is needed, it is driven in-process through the protocol's saveMetaItem / deleteMetaItem.
.changeset/21978-*.md (@objectstack/metadata-protocol patch).
Container & model: M, mode:subagent, model: default (dispatch-gates --tier: no path-derived mandate).
Clause-②: no
put and delete accept one more parent for a checksum-less row, the version the repository's own read serves. No exported type moves. The dev measures this against the built entry declarations and reports it.
Thread-read: 6014717866
Serial constraints cleared: at 2026-10-06T11:29Z:
{
"issue": 21978,
"status": "done",
"branch": "claude/issue-21978-served-version-compare",
"pr": "https://github.com/objectstack-ai/objectstack/pull/21990",
"session": "session_017ErfyP2Rx7XWHJA27QjyUi — the harness-stamped id of this run (a subagent, so it is the dispatching PM's)",
"premise_still_valid": true,
"summary": "The premise holds at BASE 8a399b2b15 at the card's exact lines: rowToItem (:2013) serves row.checksum ?? hashSpec(body, type), while put (:634) and delete (:815) compare the parent with existing.checksum ?? null. The reverse verification reproduced the card's text verbatim ('Expected parent hmac-sha256:… but current is null'). In sys-metadata-repository.ts, one helper servedVersion(ref,row) now names a row's served version and rowToItem reads it, and one lock lockAccepts(ref,row,parent) used by put and delete accepts the stored stamp as before, plus the served version for a checksum-less row. A row with a checksum is judged exactly as before, a null parent still matches a checksum-less row, and a conflict on such a row names its served version (lockHead) instead of null. 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. The same lock change also repairs callers the card did not name: unpinned (last-write-wins) save/delete, publish over such a row, rollback/commit revert (read in code), and the post-promotion drain, which silently left a checksum-less draft pending. The census finds no writer that still produces checksum-less rows, so there is no follow-up card. PR #21990 is a draft, opened with 'Fixes #21978' then 'Clause-②: no'.",
"tests": "All on the final HEAD 81606021e2 unless stated otherwise. (1) Through the verify lock: turbo build of the metadata-protocol closure (13 tasks), then `pnpm --filter @objectstack/metadata-protocol test`, giving 'Test Files 218 passed | 3 skipped (221)' and 'Tests 28028 passed | 19 skipped (28047)', then `pnpm --filter @objectstack/metadata-protocol typecheck`, giving tsc --noEmit clean; 'VERDICT command-exit 0'. The test file is in the tsc program (`tsc --noEmit --listFiles` count 1). (2) New pins: 9 cases appended to src/protocol.served-content-hash.test.ts, reusing its existing engine double (no new fake engine). Pins (a)-(d), publish over a checksum-less active row, the drain of a checksum-less draft, and repository-level stamped/unstamped lock pins. The file reads 23 passed. (3) Reverse verification on committed HEAD 5c4815a6ab, through scripts/ablation-replace.mjs (WRAP mode, its own EXIT/INT/TERM restore plus my own trap, absolute REPO_ROOT paths). It restored the raw compare `if (opts.parentVersion !== existingHash)` with actualHead existingHash in put and delete. The tool printed 'anchor hits 2 time(s), as declared', 'anchor x2 → x0', 'replace x0 → x2' and 'blob dc58518587cf → 494fa3f0eecb'; my on-disk grep -c read raw-compare 0→2 and lockAccepts-call 2→0. Predicted beforehand: 7 red (save, reset, unpinned, stale, publish, drain, repo-unstamped), with null-parent and stamped-row green. Observed 'Tests 7 failed | 16 passed (23)', exactly those 7. Save door error: 'view/case_grid has been modified since you loaded it. Expected parent hmac-sha256:e532d121… but current is null.' Drain pin: draft row still present. Repo pin: actualHead null. Restore proven by observation, not by exit code: tool 'blob after restore dc58518587cf == blob at HEAD', 'git diff HEAD empty'; my script AFTER_BLOB dc58518587cfe675843788f49a226a472dc434a3 == HEAD_BLOB, git-diff-HEAD-bytes=0, status-porcelain empty. The first ablation invocation was a no-op: exit 2 'outside the repository at /home/user/objectstack', because the tool takes the repo from cwd; on-disk counts were unchanged (0/2 before and after). It was rerun after cd REPO_ROOT. Resolution path: the subject is imported via relative src (./protocol.js, ./sys-metadata-repository.js), so no dist/ is on the path and no rebuild leg is owed. (4) Clause-② measurement: see clause_2.",
"mcp_calls": "0 — no MCP GitHub tool was called. Reads went through REST GETs via the gh CLI (issue #21978 and its comments, PR #21990 read-back, the #21923 report for format) and the scripts' own reads.",
"api_writes": "3 REST writes, each one relay stroke POST /repos/objectstack-ai/objectstack/dispatches executed by .github/workflows/fleet-write.yml as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls, giving draft #21990 (run 37466248528, read-back 10984/10984 bytes identical); (2) label-write --assign os-project-manager, i.e. POST /repos/objectstack-ai/objectstack/issues/21990/assignees (run 37466352199, read-back assignees os-project-manager); (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/21978/comments. Plus 5 git pushes to the feature branch only (empty-branch probe, fix commit, pins commit, merge 1, merge 2), which are not REST. No label was added: the dispatch named none, and a changeset exists, so skip-changeset does not apply.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed — 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'. That is false after PR #21990, and the null it passes stays valid. Comment drift in another package; it is in PR #21990's Acceptance notes.",
"carrier: 承接者:无 · noted, not filed — DraftDrainFailure.draftHash (sys-metadata-repository.ts) is documented as 'the row's checksum', but it carries the served version, the same value for every stamped row. A doc imprecision that predates PR #21990; in its Acceptance notes."
],
"gates": {
"head": "81606021e2 (after merging origin/main twice; the second merge brought PR #21979's protocol.ts change, so everything was rerun on this head; a first full pass on cd0ec1039e was also green)",
"derived": "`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 63 commands over 4 paths (+343/-12), the same set as at cd0ec1039e. All 63 exit 0. check:type-check-debt ran under the verify lock ('1 ledger entr(ies) re-measured in 234.1s, 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 --filter=!@objectstack/docs` (72 tasks, 71 cached).",
"reconciliation": "`dispatch-gates --ran` with 121 'command :: exit N' records: 'Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN'; 'EXIT CODES — all 63 accounted famil(ies) carry one'.",
"artifact_roster_block": "55 families printed outside the total: 52 exit 0 locally. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths answered NOT WIRED (exit 2, no PR context) and were rerun with PR #21990's context, each exit 0 ('PR #21990 closes #21978, and each carries a Claim: whose Branch: line names claude/issue-21978-served-version-compare'). 17 of the 55 are checker-health --self-test runs only.",
"symbol_anchor_sweeps": "pnpm check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors: all exit 0.",
"package": "@objectstack/metadata-protocol test 28028 passed / typecheck clean on 81606021e2 (lock VERDICT command-exit 0).",
"not_measured": "NOT MEASURED: path-scheduled CI jobs (Test Core shards, Temporal Conformance live PG+MySQL, Dogfood Regression Gate, Dogfood Verify CLI, Build Core), reason: CI's own shell, with no local invocation. NOT MEASURED: the workspace type-check lanes in lint.yml, reason: CI-wide filters. NOT MEASURED: the 6 workflow-valued families (shard attestation, test completeness, issue-citations --census), reason: their argv needs CI variables. NOT MEASURED: the 11 wide-population families, reason: no path narrows them. NOT MEASURED: check-changeset-no-major's Clause-② axis, reason: PR-scoped with no pull_request payload locally (its base run exited 0). The dogfood pin datasource-meta-door-reaches-admin-door.dogfood.test.ts was not run locally; it belongs to the Dogfood Regression Gate."
},
"line_budget": "n/a",
"deviations": [
"Ablation: the first invocation was a no-op (scripts/ablation-replace.mjs exit 2, which took the repository from the shared checkout's cwd); it wrote nothing (on-disk counts unchanged), and the rerun from the worktree root is the measurement reported.",
"check:type-check-debt first hit my own 300s per-command timeout (exit 124, no verdict); it was rerun under the verify lock and passed (exit 0).",
"check:dual-build-cjs-loads and check:lean-entry-closure first answered PREREQUISITE NOT MET (exit 3, not a verdict); a full turbo build (71/72 cached) was run, and both then passed (exit 0).",
"One gate batch overran the foreground cap and the harness moved it to the background. I kept the turn alive and waited in the foreground with `tail --pid` on the PID I started until it exited, then read its results file.",
"origin/main moved twice: merged once before the gates (Version Packages #21352) and again after #21979 touched protocol.ts; the full union was rerun on 81606021e2. It has since moved by 2 more commits (#21981, #21983: platform-objects, plugin-sharing, lint, spec docs; none touches metadata-protocol), which were not merged.",
"Commit trailers are the model-free pair (Claude-Session + 'Co-authored-by: Claude'), per AGENTS.md, not the harness reminder's model-named Co-Authored-By. The PR footer is the AGENTS.md session-URL form. Commits carry no card trailer.",
"Beyond the two named files, one comment in packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts was edited (4 lines). It said a checksum-less draft survives the drain, which this change makes false; the same package, no behaviour.",
"Clean-up runs after this comment is posted: rm -rf the worktree's node_modules, then `git worktree remove` without --force. Its result is therefore not in this JSON."
],
"files_changed": [
"packages/metadata-protocol/src/sys-metadata-repository.ts (+96 -8): storedRowBody(), servedVersion(), lockHead(), lockAccepts(); put/delete lock; rowToItem reads servedVersion; header non-goal line",
"packages/metadata-protocol/src/protocol.served-content-hash.test.ts (+231 -0): the #21978 pins, reusing the file's engine double",
"packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts (+4 -4): fixture comment made false by this change",
".changeset/21978-checksum-less-row-served-version.md (+12): '@objectstack/metadata-protocol': patch, with Clause-②: no"
],
"clause_2": "no. Measured against the built entry declarations: packages/metadata-protocol/dist/index.d.ts built at HEAD, and again with BASE 8a399b2b15's sys-metadata-repository.ts swapped in behind a trap (on-disk blob 80789431dc == base blob before the build; restored and proven, blob dc58518587 == HEAD, git diff HEAD empty; HEAD rebuilt with 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 (52 added / 1 removed lines in total). index.d.cts has the identical diff. No exported type, signature or entry moves. Behaviourally, put/delete accept for a checksum-less row the version the same repository already serves for it (the declared version token), not a new input class.",
"H1": {
"verdict": "Confirmed at BASE 8a399b2b15. Each site below is labelled with the spelling it reads: SERVED is checksum ?? hashSpec(body, type) via rowToItem; RAW is the column.",
"sites": [
"rowToItem :2013: SERVED. Feeds get (:472-495), list (:1176), the put no-op re-yield (:651), promoteDraft's draft and currentActive (:1010, :1020) and restoreVersion's currentActive (:1150).",
"get :472-495: SERVED (via rowToItem).",
"getByHash :505-539: the history table, looked up WHERE checksum = the hash argument, which it echoes; parentHash is RAW previous_checksum ?? null. It does not read a sys_metadata row's version, and history rows written by put always carry a checksum.",
"put lock :634-637: RAW existing?.checksum ?? null. This is the defect; it is now lockAccepts (RAW or, for a checksum-less row, SERVED).",
"put no-op check storedBodyUnchanged(..., existingHash, hash) :650: RAW, kept deliberately, so a checksum-less row never short-circuits and an identical re-save stamps it.",
"put lineage previous_checksum :715, item.parentHash :732, broadcast parentHash :770: RAW, kept.",
"delete lock :815-818: RAW. The defect; now lockAccepts.",
"delete tombstone previous_checksum :858: RAW, kept.",
"promoteDraft expectedDraftHash (#21934) :1014: SERVED vs the caller's expectation. The protocol takes it from its own repo.get (SERVED), so it was already consistent at BASE.",
"promoteDraft parent :1023: SERVED currentActive.hash goes to put, which at BASE compared RAW, so a 409 for a checksum-less active row. Pinned (publish).",
"dropPromotedDraftRow :1045/:2270: SERVED draft.hash goes to delete, which at BASE compared RAW, giving a ConflictError that draftDrainVerdict silences as a benign race, so a checksum-less draft survived publish silently. Pinned (drain).",
"restoreVersion parent :1152: SERVED goes to put, a 409 at BASE (read in code, not pinned).",
"history()/rowToEvent :1319: the history table's RAW checksum ?? null; a tombstone reads back as op 'delete' with hash null, correct by design.",
"ConflictError actualHead (:636, :817): RAW at BASE (null for a checksum-less row, hence 'current is null'); now SERVED via lockHead.",
"Door callers in protocol.ts: saveMetaItem's parent (:20381 unpinned / :20389 token) is SERVED current.hash; deleteMetaItem's (:25992) is SERVED; revertCommit's (:24799) is SERVED; migrateStoredMetadata (:21060) passes RAW row.checksum ?? null; flow-credential-migration calls saveMetaItem with no parent, so SERVED. All except migrateStoredMetadata hit the 409 at BASE."
]
},
"H2": {
"verdict": "Confirmed and implemented as hypothesised.",
"detail": "lockAccepts(ref,row,parent) = parent === (row.checksum ?? null) || (row.checksum ?? null) === null && parent !== null && parent === lockHead(ref,row). The first arm is BASE's compare byte for byte, so a stamped row is judged exactly as before; this is pinned on a row whose stamp differs from its body's hash, where the body's hash and null are refused and the stamp is accepted. Callers passing null for a checksum-less row are migrateStoredMetadata's storedParentVersion: row.checksum ?? null (pinned, succeeds) and saveMetaItem with parentVersion null (storedParentForToken(null) gives null, still matched; e.g. the CLI integration probe). The datasource admin door's write path calls neither put nor delete: writeDatasourceRow / its delete use engine.insert/update/delete directly with no optimistic lock (datasource-admin-plugin.ts :115-165), so this change does not affect it. DeleteOptions.parentVersion is typed string, so no in-tree delete caller passes null; the null match is kept for delete anyway, for symmetry."
},
"H3": {
"verdict": "No writer in this repository still stores a sys_metadata row with a null or absent checksum, after PR #21977 or after this fix, so there is no follow-up card.",
"census_table": [
{"writer": "SysMetadataRepository.put (insert/update)", "where": "packages/metadata-protocol/src/sys-metadata-repository.ts", "checksum": "always hashSpec(body, type)", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "SysMetadataRepository.delete", "where": "same file", "checksum": "removes the sys_metadata row; the tombstone goes to sys_metadata_history with checksum null by design", "live_after_21977": "n/a (history table)", "live_after_fix": "n/a"},
{"writer": "datasource admin door writeDatasourceRow", "where": "packages/services/service-datasource/src/datasource-admin-plugin.ts", "checksum": "hashSpec(record, 'datasource') since PR #21977; none before (the legacy population)", "live_after_21977": "no", "live_after_fix": "no; its legacy rows are writable via the served version"},
{"writer": "datasource admin door delete fallback update {state: 'inactive'}", "where": "same file", "checksum": "partial update, keeps the column", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "DatabaseLoader save / create / registerRollback", "where": "packages/metadata/src/loaders/database-loader.ts", "checksum": "contentHash stamp", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "protocol orphan adoption (package_id rebind)", "where": "packages/metadata-protocol/src/protocol.ts", "checksum": "partial update, keeps the column", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "protocol legacy delete; permission-set overlay discard", "where": "protocol.ts; packages/plugins/plugin-security/src/permission-set-overlay-discard.ts", "checksum": "delete only", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "env_id to project_id migration", "where": "packages/metadata/src/migrations/migrate-env-id-to-project-id.ts", "checksum": "column rename DDL", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "migrateStoredMetadata; flow credential move", "where": "protocol.ts; packages/services/service-automation/src/flow-credential-migration.ts", "checksum": "through saveMetaItem then put (stamps)", "live_after_21977": "no (both 409'd on checksum-less rows)", "live_after_fix": "no (both now succeed)"},
{"writer": "generic data door, MCP data bridge, flow write nodes, hook bodies", "where": "sys_metadata object apiMethods ['get','list']; stored-metadata family refusals", "checksum": "refused", "live_after_21977": "no", "live_after_fix": "no"},
{"writer": "seed datasets", "where": "packages/metadata-protocol/src/seed-loader.ts", "checksum": "no dataset in the tree targets sys_metadata", "live_after_21977": "no producer", "live_after_fix": "no producer"}
],
"tombstone_read_back": "A tombstone lives in sys_metadata_history (metadata null, checksum null, previous_checksum = the deleted row's raw stamp). history()/rowToEvent yields op 'delete' with hash null; getByHash never matches it (WHERE checksum = a string, and metadata null returns null); restoreVersion refuses it with VERSION_NOT_RESTORABLE (409)."
}
}
Landed: PR #21990 → 6befe19c6e on main. It merged through the merge queue at 2026-10-06T14:01Z, after entering the queue at 2026-10-06T13:33Z. Verified at 2026-10-06T14:02Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.
The squash. It is on origin/main as a single-parent commit (parent aa09db58c9). Its diffstat is the reviewed one: 4 files, +343/-12.
What is on main.
sys-metadata-repository.ts names a row's served version once, in servedVersion, which rowToItem reads.
put and delete lock through lockAccepts. A stamped row is judged exactly as before. A checksum-less row also accepts the version its read hands out, and a null parent still matches it.
The next write stamps the row. There is no backfill and no migration.
The same lock repairs four other paths for such rows: publish, rollback and revert, and the post-promotion drain.
The card.Fixes #21978 closed this card as completed. pm:dispatched is removed in this act. No other card was closed by the body.
From this release (@objectstack/metadata-protocol patch, Clause-②: no).
Activity
objectstack-fleet commentedon Oct 6, 2026
Path: 外部数据源接进来当自己的对象用 (the metadata door,
api) | 缺项 (no item asserts the metadata door's read and write agree on a row's version) | P3Triage: first grade —
bug·priority:p3·domain:engine·area:api·pm:queue. The compare uses the version the read hands out; no backfillTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-06T10:53Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/metadata-protocol/src/sys-metadata-repository.ts⇒domain:engine; rationale: one repository hands out one version for a row and checks a different one.Verified on
main(dcf3eb494a):rowToItem(about:2013) servesrow.checksum ?? hashSpec(body, ref.type).put(about:634) anddelete(about:815) compare the caller's parent withexisting.checksum ?? null.checksumcan therefore never match the version its own read handed out.Direction:
putanddeleteall use it.checksum,putanddeletealso accept that served version. A row with achecksumis judged exactly as today.nullparent matches such a row, and some writer may rely on that, the admin door's write path first among them. That match stays.checksumas usual.Pins:
checksumis updated and deleted through the metadata door with the version its read returned.409 METADATA_CONFLICT.nullfor such a row today still succeeds.Why p3:
Serial: #21922 (
pm:queue, the same lane) changesgetMetaItem's stored-row step, not this file. No overlap is expected; the claim confirms it.The census of other
sys_metadatawriters that omitchecksumis the claim's, as the card says. One that is still live after this fix is a follow-up card.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Claim: PM loop round 45 · 2026-10-06T11:29Z
Session:
session_017ErfyP2Rx7XWHJA27QjyUiAccount:
os-project-manager(the seat's linked user, asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21978-served-version-compareWorktree:
objectstack-issue-21978Domain:
domain:engineSeat:
domain:engine#1Provenance: triage graded this card
pm:queue(6014717866). It is the only eligiblepm:queuecard in this lane. The lane runs three concurrent claims (the maintainer, verbatim: 「并发3」); the other two dev slots are #21972 and #21922, and #21967 waits in the merge queue.File surface (at
origin/main8a399b2b15), per triage's direction 6014717866:packages/metadata-protocol/src/sys-metadata-repository.ts:row.checksum ?? hashSpec(body, ref.type), asrowToItemserves it (about:2013);put(about:634) anddelete(about:815) compare through it. For a row with nochecksum, they also accept that served version.checksumis judged exactly as today. Anullparent still matches achecksum-less row (⛔ nothing narrowed).checksumas usual.sys_metadatawriters that omitchecksumis this claim's, as the card says. Each one still live after the fix is reported in the PR, for a follow-up card.checksumis updated and deleted through the metadata door with the version its read returned;409 METADATA_CONFLICT;nullfor such a row still succeeds.metadata-protocol. If a door-level pin is needed, it is driven in-process through the protocol'ssaveMetaItem/deleteMetaItem..changeset/21978-*.md(@objectstack/metadata-protocolpatch).Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).Clause-②: no
putanddeleteaccept one more parent for achecksum-less row, the version the repository's own read serves. No exported type moves. The dev measures this against the built entry declarations and reports it.Thread-read: 6014717866
Serial constraints cleared: at 2026-10-06T11:29Z:
domain:services), is merged.filename: none touchessys-metadata-repository.ts. This lane's PR fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved #21979 (finding(metadata-protocol): a saved env-wide copy of a view container leaves its own expansion alone per name in the env-wide view list, so the anonymous form doors can miss another package's withdrawal, saved or shipped (#21934 item 1's residual) #21967) touchesprotocol.tsonly.protocol.ts'sgetMetaItemstored-row step, not this file, which confirms triage's expectation. finding(platform-objects): six more Setup object entries open their object's caller-scoped first list view (mine / granted_to_me), and sys_user's bare-object doors still openme(#21960's family, from PR #21971) #21972 (platform-objects) and [finding]sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 (sys-member.object.ts) share no file with this card.objectstack-fleet commentedon Oct 6, 2026
os-dev-report
{ "issue": 21978, "status": "done", "branch": "claude/issue-21978-served-version-compare", "pr": "https://github.com/objectstack-ai/objectstack/pull/21990", "session": "session_017ErfyP2Rx7XWHJA27QjyUi — the harness-stamped id of this run (a subagent, so it is the dispatching PM's)", "premise_still_valid": true, "summary": "The premise holds at BASE 8a399b2b15 at the card's exact lines: rowToItem (:2013) serves row.checksum ?? hashSpec(body, type), while put (:634) and delete (:815) compare the parent with existing.checksum ?? null. The reverse verification reproduced the card's text verbatim ('Expected parent hmac-sha256:… but current is null'). In sys-metadata-repository.ts, one helper servedVersion(ref,row) now names a row's served version and rowToItem reads it, and one lock lockAccepts(ref,row,parent) used by put and delete accepts the stored stamp as before, plus the served version for a checksum-less row. A row with a checksum is judged exactly as before, a null parent still matches a checksum-less row, and a conflict on such a row names its served version (lockHead) instead of null. 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. The same lock change also repairs callers the card did not name: unpinned (last-write-wins) save/delete, publish over such a row, rollback/commit revert (read in code), and the post-promotion drain, which silently left a checksum-less draft pending. The census finds no writer that still produces checksum-less rows, so there is no follow-up card. PR #21990 is a draft, opened with 'Fixes #21978' then 'Clause-②: no'.", "tests": "All on the final HEAD 81606021e2 unless stated otherwise. (1) Through the verify lock: turbo build of the metadata-protocol closure (13 tasks), then `pnpm --filter @objectstack/metadata-protocol test`, giving 'Test Files 218 passed | 3 skipped (221)' and 'Tests 28028 passed | 19 skipped (28047)', then `pnpm --filter @objectstack/metadata-protocol typecheck`, giving tsc --noEmit clean; 'VERDICT command-exit 0'. The test file is in the tsc program (`tsc --noEmit --listFiles` count 1). (2) New pins: 9 cases appended to src/protocol.served-content-hash.test.ts, reusing its existing engine double (no new fake engine). Pins (a)-(d), publish over a checksum-less active row, the drain of a checksum-less draft, and repository-level stamped/unstamped lock pins. The file reads 23 passed. (3) Reverse verification on committed HEAD 5c4815a6ab, through scripts/ablation-replace.mjs (WRAP mode, its own EXIT/INT/TERM restore plus my own trap, absolute REPO_ROOT paths). It restored the raw compare `if (opts.parentVersion !== existingHash)` with actualHead existingHash in put and delete. The tool printed 'anchor hits 2 time(s), as declared', 'anchor x2 → x0', 'replace x0 → x2' and 'blob dc58518587cf → 494fa3f0eecb'; my on-disk grep -c read raw-compare 0→2 and lockAccepts-call 2→0. Predicted beforehand: 7 red (save, reset, unpinned, stale, publish, drain, repo-unstamped), with null-parent and stamped-row green. Observed 'Tests 7 failed | 16 passed (23)', exactly those 7. Save door error: 'view/case_grid has been modified since you loaded it. Expected parent hmac-sha256:e532d121… but current is null.' Drain pin: draft row still present. Repo pin: actualHead null. Restore proven by observation, not by exit code: tool 'blob after restore dc58518587cf == blob at HEAD', 'git diff HEAD empty'; my script AFTER_BLOB dc58518587cfe675843788f49a226a472dc434a3 == HEAD_BLOB, git-diff-HEAD-bytes=0, status-porcelain empty. The first ablation invocation was a no-op: exit 2 'outside the repository at /home/user/objectstack', because the tool takes the repo from cwd; on-disk counts were unchanged (0/2 before and after). It was rerun after cd REPO_ROOT. Resolution path: the subject is imported via relative src (./protocol.js, ./sys-metadata-repository.js), so no dist/ is on the path and no rebuild leg is owed. (4) Clause-② measurement: see clause_2.", "mcp_calls": "0 — no MCP GitHub tool was called. Reads went through REST GETs via the gh CLI (issue #21978 and its comments, PR #21990 read-back, the #21923 report for format) and the scripts' own reads.", "api_writes": "3 REST writes, each one relay stroke POST /repos/objectstack-ai/objectstack/dispatches executed by .github/workflows/fleet-write.yml as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls, giving draft #21990 (run 37466248528, read-back 10984/10984 bytes identical); (2) label-write --assign os-project-manager, i.e. POST /repos/objectstack-ai/objectstack/issues/21990/assignees (run 37466352199, read-back assignees os-project-manager); (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/21978/comments. Plus 5 git pushes to the feature branch only (empty-branch probe, fix commit, pins commit, merge 1, merge 2), which are not REST. No label was added: the dispatch named none, and a changeset exists, so skip-changeset does not apply.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed — 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'. That is false after PR #21990, and the null it passes stays valid. Comment drift in another package; it is in PR #21990's Acceptance notes.", "carrier: 承接者:无 · noted, not filed — DraftDrainFailure.draftHash (sys-metadata-repository.ts) is documented as 'the row's checksum', but it carries the served version, the same value for every stamped row. A doc imprecision that predates PR #21990; in its Acceptance notes." ], "gates": { "head": "81606021e2 (after merging origin/main twice; the second merge brought PR #21979's protocol.ts change, so everything was rerun on this head; a first full pass on cd0ec1039e was also green)", "derived": "`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 63 commands over 4 paths (+343/-12), the same set as at cd0ec1039e. All 63 exit 0. check:type-check-debt ran under the verify lock ('1 ledger entr(ies) re-measured in 234.1s, 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 --filter=!@objectstack/docs` (72 tasks, 71 cached).", "reconciliation": "`dispatch-gates --ran` with 121 'command :: exit N' records: 'Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN'; 'EXIT CODES — all 63 accounted famil(ies) carry one'.", "artifact_roster_block": "55 families printed outside the total: 52 exit 0 locally. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths answered NOT WIRED (exit 2, no PR context) and were rerun with PR #21990's context, each exit 0 ('PR #21990 closes #21978, and each carries a Claim: whose Branch: line names claude/issue-21978-served-version-compare'). 17 of the 55 are checker-health --self-test runs only.", "symbol_anchor_sweeps": "pnpm check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors: all exit 0.", "package": "@objectstack/metadata-protocol test 28028 passed / typecheck clean on 81606021e2 (lock VERDICT command-exit 0).", "not_measured": "NOT MEASURED: path-scheduled CI jobs (Test Core shards, Temporal Conformance live PG+MySQL, Dogfood Regression Gate, Dogfood Verify CLI, Build Core), reason: CI's own shell, with no local invocation. NOT MEASURED: the workspace type-check lanes in lint.yml, reason: CI-wide filters. NOT MEASURED: the 6 workflow-valued families (shard attestation, test completeness, issue-citations --census), reason: their argv needs CI variables. NOT MEASURED: the 11 wide-population families, reason: no path narrows them. NOT MEASURED: check-changeset-no-major's Clause-② axis, reason: PR-scoped with no pull_request payload locally (its base run exited 0). The dogfood pin datasource-meta-door-reaches-admin-door.dogfood.test.ts was not run locally; it belongs to the Dogfood Regression Gate." }, "line_budget": "n/a", "deviations": [ "Ablation: the first invocation was a no-op (scripts/ablation-replace.mjs exit 2, which took the repository from the shared checkout's cwd); it wrote nothing (on-disk counts unchanged), and the rerun from the worktree root is the measurement reported.", "check:type-check-debt first hit my own 300s per-command timeout (exit 124, no verdict); it was rerun under the verify lock and passed (exit 0).", "check:dual-build-cjs-loads and check:lean-entry-closure first answered PREREQUISITE NOT MET (exit 3, not a verdict); a full turbo build (71/72 cached) was run, and both then passed (exit 0).", "One gate batch overran the foreground cap and the harness moved it to the background. I kept the turn alive and waited in the foreground with `tail --pid` on the PID I started until it exited, then read its results file.", "origin/main moved twice: merged once before the gates (Version Packages #21352) and again after #21979 touched protocol.ts; the full union was rerun on 81606021e2. It has since moved by 2 more commits (#21981, #21983: platform-objects, plugin-sharing, lint, spec docs; none touches metadata-protocol), which were not merged.", "Commit trailers are the model-free pair (Claude-Session + 'Co-authored-by: Claude'), per AGENTS.md, not the harness reminder's model-named Co-Authored-By. The PR footer is the AGENTS.md session-URL form. Commits carry no card trailer.", "Beyond the two named files, one comment in packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts was edited (4 lines). It said a checksum-less draft survives the drain, which this change makes false; the same package, no behaviour.", "Clean-up runs after this comment is posted: rm -rf the worktree's node_modules, then `git worktree remove` without --force. Its result is therefore not in this JSON." ], "files_changed": [ "packages/metadata-protocol/src/sys-metadata-repository.ts (+96 -8): storedRowBody(), servedVersion(), lockHead(), lockAccepts(); put/delete lock; rowToItem reads servedVersion; header non-goal line", "packages/metadata-protocol/src/protocol.served-content-hash.test.ts (+231 -0): the #21978 pins, reusing the file's engine double", "packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts (+4 -4): fixture comment made false by this change", ".changeset/21978-checksum-less-row-served-version.md (+12): '@objectstack/metadata-protocol': patch, with Clause-②: no" ], "clause_2": "no. Measured against the built entry declarations: packages/metadata-protocol/dist/index.d.ts built at HEAD, and again with BASE 8a399b2b15's sys-metadata-repository.ts swapped in behind a trap (on-disk blob 80789431dc == base blob before the build; restored and proven, blob dc58518587 == HEAD, git diff HEAD empty; HEAD rebuilt with 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 (52 added / 1 removed lines in total). index.d.cts has the identical diff. No exported type, signature or entry moves. Behaviourally, put/delete accept for a checksum-less row the version the same repository already serves for it (the declared version token), not a new input class.", "H1": { "verdict": "Confirmed at BASE 8a399b2b15. Each site below is labelled with the spelling it reads: SERVED is checksum ?? hashSpec(body, type) via rowToItem; RAW is the column.", "sites": [ "rowToItem :2013: SERVED. Feeds get (:472-495), list (:1176), the put no-op re-yield (:651), promoteDraft's draft and currentActive (:1010, :1020) and restoreVersion's currentActive (:1150).", "get :472-495: SERVED (via rowToItem).", "getByHash :505-539: the history table, looked up WHERE checksum = the hash argument, which it echoes; parentHash is RAW previous_checksum ?? null. It does not read a sys_metadata row's version, and history rows written by put always carry a checksum.", "put lock :634-637: RAW existing?.checksum ?? null. This is the defect; it is now lockAccepts (RAW or, for a checksum-less row, SERVED).", "put no-op check storedBodyUnchanged(..., existingHash, hash) :650: RAW, kept deliberately, so a checksum-less row never short-circuits and an identical re-save stamps it.", "put lineage previous_checksum :715, item.parentHash :732, broadcast parentHash :770: RAW, kept.", "delete lock :815-818: RAW. The defect; now lockAccepts.", "delete tombstone previous_checksum :858: RAW, kept.", "promoteDraft expectedDraftHash (#21934) :1014: SERVED vs the caller's expectation. The protocol takes it from its own repo.get (SERVED), so it was already consistent at BASE.", "promoteDraft parent :1023: SERVED currentActive.hash goes to put, which at BASE compared RAW, so a 409 for a checksum-less active row. Pinned (publish).", "dropPromotedDraftRow :1045/:2270: SERVED draft.hash goes to delete, which at BASE compared RAW, giving a ConflictError that draftDrainVerdict silences as a benign race, so a checksum-less draft survived publish silently. Pinned (drain).", "restoreVersion parent :1152: SERVED goes to put, a 409 at BASE (read in code, not pinned).", "history()/rowToEvent :1319: the history table's RAW checksum ?? null; a tombstone reads back as op 'delete' with hash null, correct by design.", "ConflictError actualHead (:636, :817): RAW at BASE (null for a checksum-less row, hence 'current is null'); now SERVED via lockHead.", "Door callers in protocol.ts: saveMetaItem's parent (:20381 unpinned / :20389 token) is SERVED current.hash; deleteMetaItem's (:25992) is SERVED; revertCommit's (:24799) is SERVED; migrateStoredMetadata (:21060) passes RAW row.checksum ?? null; flow-credential-migration calls saveMetaItem with no parent, so SERVED. All except migrateStoredMetadata hit the 409 at BASE." ] }, "H2": { "verdict": "Confirmed and implemented as hypothesised.", "detail": "lockAccepts(ref,row,parent) = parent === (row.checksum ?? null) || (row.checksum ?? null) === null && parent !== null && parent === lockHead(ref,row). The first arm is BASE's compare byte for byte, so a stamped row is judged exactly as before; this is pinned on a row whose stamp differs from its body's hash, where the body's hash and null are refused and the stamp is accepted. Callers passing null for a checksum-less row are migrateStoredMetadata's storedParentVersion: row.checksum ?? null (pinned, succeeds) and saveMetaItem with parentVersion null (storedParentForToken(null) gives null, still matched; e.g. the CLI integration probe). The datasource admin door's write path calls neither put nor delete: writeDatasourceRow / its delete use engine.insert/update/delete directly with no optimistic lock (datasource-admin-plugin.ts :115-165), so this change does not affect it. DeleteOptions.parentVersion is typed string, so no in-tree delete caller passes null; the null match is kept for delete anyway, for symmetry." }, "H3": { "verdict": "No writer in this repository still stores a sys_metadata row with a null or absent checksum, after PR #21977 or after this fix, so there is no follow-up card.", "census_table": [ {"writer": "SysMetadataRepository.put (insert/update)", "where": "packages/metadata-protocol/src/sys-metadata-repository.ts", "checksum": "always hashSpec(body, type)", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "SysMetadataRepository.delete", "where": "same file", "checksum": "removes the sys_metadata row; the tombstone goes to sys_metadata_history with checksum null by design", "live_after_21977": "n/a (history table)", "live_after_fix": "n/a"}, {"writer": "datasource admin door writeDatasourceRow", "where": "packages/services/service-datasource/src/datasource-admin-plugin.ts", "checksum": "hashSpec(record, 'datasource') since PR #21977; none before (the legacy population)", "live_after_21977": "no", "live_after_fix": "no; its legacy rows are writable via the served version"}, {"writer": "datasource admin door delete fallback update {state: 'inactive'}", "where": "same file", "checksum": "partial update, keeps the column", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "DatabaseLoader save / create / registerRollback", "where": "packages/metadata/src/loaders/database-loader.ts", "checksum": "contentHash stamp", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "protocol orphan adoption (package_id rebind)", "where": "packages/metadata-protocol/src/protocol.ts", "checksum": "partial update, keeps the column", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "protocol legacy delete; permission-set overlay discard", "where": "protocol.ts; packages/plugins/plugin-security/src/permission-set-overlay-discard.ts", "checksum": "delete only", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "env_id to project_id migration", "where": "packages/metadata/src/migrations/migrate-env-id-to-project-id.ts", "checksum": "column rename DDL", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "migrateStoredMetadata; flow credential move", "where": "protocol.ts; packages/services/service-automation/src/flow-credential-migration.ts", "checksum": "through saveMetaItem then put (stamps)", "live_after_21977": "no (both 409'd on checksum-less rows)", "live_after_fix": "no (both now succeed)"}, {"writer": "generic data door, MCP data bridge, flow write nodes, hook bodies", "where": "sys_metadata object apiMethods ['get','list']; stored-metadata family refusals", "checksum": "refused", "live_after_21977": "no", "live_after_fix": "no"}, {"writer": "seed datasets", "where": "packages/metadata-protocol/src/seed-loader.ts", "checksum": "no dataset in the tree targets sys_metadata", "live_after_21977": "no producer", "live_after_fix": "no producer"} ], "tombstone_read_back": "A tombstone lives in sys_metadata_history (metadata null, checksum null, previous_checksum = the deleted row's raw stamp). history()/rowToEvent yields op 'delete' with hash null; getByHash never matches it (WHERE checksum = a string, and metadata null returns null); restoreVersion refuses it with VERSION_NOT_RESTORABLE (409)." } }Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Landed: PR #21990 →
6befe19c6eonmain. It merged through the merge queue at 2026-10-06T14:01Z, after entering the queue at 2026-10-06T13:33Z. Verified at 2026-10-06T14:02Z.domain:engine#1·session_017ErfyP2Rx7XWHJA27QjyUi.origin/mainas a single-parent commit (parentaa09db58c9). Its diffstat is the reviewed one: 4 files, +343/-12.main.sys-metadata-repository.tsnames a row's served version once, inservedVersion, whichrowToItemreads.putanddeletelock throughlockAccepts. A stamped row is judged exactly as before. Achecksum-less row also accepts the version its read hands out, and anullparent still matches it.Fixes #21978closed this card ascompleted.pm:dispatchedis removed in this act. No other card was closed by the body.@objectstack/metadata-protocolpatch,Clause-②: no).sys_metadatarow written before 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 without achecksumcan be edited and removed through the metadata door with the version its read returns. Before, it answered409 METADATA_CONFLICT.Generated by Claude Code
fix(metadata-protocol): put and delete accept the version a checksum-…