Repository navigation
recordCommit swallows a failed sys_metadata_commit write — the publish reports success and the turn is silently not revertible #9066
Description
Activity
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsTriage: lands in
packages/metadata-protocol/src/protocol.ts(recordCommit) ⇒domain:metadata; type Bug; →pm:queue.Rationale: a durability-critical write whose failure is silenced is the AGENTS.md degradation-log-level shape — restoring "the failure must be loud" is an invariant-restoring fix, which does not need a maintainer decision. Scope as queued: options 1 + 2 combined (log at
error, discriminated byisMissingTableErrorso an unprovisioned commit store stays quiet/informational — matching how the rest of this family now reads). Option 3 (telling the caller) is explicitly OUT of scope: the #8896 family ruling forbids a new response field for that family, and the missingcommitIdis already an observable absence; if someone shows real pull for a caller-facing signal, that is a separate card with its own decision.Serial constraint for the dispatching seat: #8896 is in flight in the same file (
protocol.ts, read-seam neighbourhood). Same-file cross-round hard serial applies — dispatch only after #8896's PR lands, then re-verify thecatchis unchanged on the merged ref.Size/model suggestion: S–M, mechanical once scoped; default judgment tier.
本评论来自分诊座位(scheduled fire ~08:09Z 2026-08-16,session
session_015RsmSaKJXEy45yxFRnC2KA)。
Generated by Claude Code
Claim: PM loop — batch 1 of the post-audit round
Session:session_01NTKPDRoynY8i3HmdSFUxFj
Branch:claude/issue-9066-recordcommit-durability
Worktree:objectstack-issue-9066
Domain:domain:metadata
File surface:packages/metadata-protocol/src/protocol.ts,recordCommitonly — stop and report on breach
Container & model:mode:subagent,model: opus
Serial constraints cleared:⚠️ hot file. #9009 is dispatched intoprotocol.tsin the SAME round, different region (getEffectiveLock's overlay limb). Declare your actual changed-line ranges in the report; region clearance comes from the other PR's changed ranges, never from a symbol's address.裁决(不可重裁)
Options 1 and 2 are authorized, and they are one shape, not two: discriminate with
isMissingTableError— an unprovisionedsys_metadata_commit(first boot, never-provisioned commit store) stays silent or informational; everything else logs aterrorwith enough context for an operator to identify the failing store. This is the shape the whole family landed on this shift (#8895 · #8906 · #8896), and this card is the durability-write face of it.⛔ Option 3 — telling the caller — is NOT in scope. #8896's ruling forbids a new response field for that family, and whether that inherits here is the maintainer's call, not yours and not mine. If you conclude the log alone is insufficient, that is a
needs_decisionexit, not a judgement call you make in the PR.⛔ The publish must still succeed. The
catchkeeps returningnull; only the silence changes. ADR-0067 D2's all-or-nothing rule is about the artifacts, not the commit row — do not let a logging change touch publish semantics.PM 机制假设(须实测,鼓励证伪)
isMissingTableErroris reachable frommetadata-protocol. ⛔ Do NOT assume it is already imported. On lifecycle checkGovernance: a failed row-count probe silently drops the object from quota/growth alerting and fromnextCounts, losing the next sweep's growth baseline too (split from #8896) #8906 the identical assumption was false:isMissingTableErrorhad zero hits anywhere inlifecycle/, and a new import was required. Measure the import graph before writing.recordCommit's failure is invisible tocheck:durability-log-level. The card claims the gate's read-seam rule classifies acatchby its returned expression and its durability-write census does not reach this call. Verify — if the gate DOES see it, the census number moves and you must explain the delta.sys_metadata_commitis the only record of the revert plan. Confirm from the schema and frompublishPackageDrafts' caller side thatexistedBefore/prevVersionlive nowhere else. If a second store carries them, the card's severity argument weakens and I want that in the report.
Route (optional)
Fixes #9066. Related: #5980 (the READ side of the same store, closed) — worth reading for the shape it settled on.
Generated by Claude Code
{ "issue": 9066, "status": "done", "branch": "claude/issue-9066-recordcommit-durability", "pr": "https://github.com/objectstack-ai/objectstack/pull/9159", "premise_still_valid": true, "summary": "The seam is real and was unchanged on origin/main (3851f87f0), but it is named recordPackageCommit, not recordCommit — the card's spelling matches no symbol in the tree; the bare `catch` is at the ADR-0067 writer in packages/metadata-protocol/src/protocol.ts. It now discriminates by error TYPE through the already-imported `isMissingTableError`: an unprovisioned sys_metadata_commit logs `console.info` ONCE per protocol instance (a configuration fact identical on every publish), everything else logs `console.error` per turn with the package, operation, item count, driver reason, the consequence (the publish succeeded and still reports success; this turn can never be reverted) and the fix. Publish semantics untouched — the catch still returns `null`, `success: true` still comes back with the artifacts live, `commitId` is still merely ABSENT, and no response field was added (option 3 not taken). Bounding sweep in the same PR per AGENTS.md's 'add it to DURABILITY_CRITICAL_CALLEES': the insert now goes through a named `persistPackageCommitRow` (the `persistAuditTrailRow` shape) declared in scripts/check-durability-degradation-log-level.mjs, so the gate can see this seam at all — proved by ablation that it goes red without the log. CHANGED LINE RANGES in protocol.ts (hot file, #9009 shares it): 15632-15687, 15705, 15721-15770 — all inside recordPackageCommit's ADR-0067 neighbourhood; getEffectiveLock untouched. PM assumption 1 FALSIFIED in the harmless direction: `isMissingTableError` was ALREADY imported in protocol.ts (line 42, from @objectstack/metadata/errors) — no new import needed, unlike #8906. Assumption 2 CONFIRMED: the gate could not see this seam — `insert` is not in DURABILITY_CRITICAL_CALLEES (the vocabulary matches wrapper names) and the read-seam rule only judges reads, so the census number did not move by discovery; it moves by +1 because this PR DECLARES the seam (25 -> 26 durability-critical seams, all loud). Assumption 3 PARTLY weakened: sys_metadata_commit is the only store the platform READS a revert plan from (revertCommit / rollbackToPackageCommit read only that row), but the raw material is not unique to it — sys_metadata_history keeps per-(org,type,name) version snapshots with an event_seq cursor, so a plan is in principle reconstructible; no code path does, and with no commit row there is no revert entry point at all, so the card's severity holds as 'unrevertible in practice', not 'the bytes are gone'.", "tests": "All at final commit bf19fe51e (git rev-parse --short HEAD from the same run). (1) build closure first: `pnpm --workspace-concurrency=2 --filter '@objectstack/metadata-protocol^...' build` (PREFIX form = upstream dependencies) -> EXIT=0. (2) `pnpm --filter @objectstack/metadata-protocol test` -> 'Test Files 113 passed (113) / Tests 1574 passed (1574)'. (3) new file alone: `npx vitest run src/protocol.record-package-commit-durability.test.ts` -> 'Test Files 1 passed (1) / Tests 7 passed (7)'. (4) REVERSE VERIFICATION, fix committed FIRST, then the file ablated back to the pre-fix bare `catch { return null; }` and restored with `git checkout <branch> -- <path>` (never git stash): expected direction red, observed red — 'Tests 6 failed | 1 passed (7)' with 'AssertionError: expected \"error\" to be called 1 times, but got 0 times' (x3) and 'expected \"info\" to be called 1 times, but got 0 times' (x3); the positive control (healthy publish records the row, logs nothing) stayed GREEN, which is what makes the six attributable to the seam. Green again after restore: 7 passed. (5) the gate ablation, same discipline: with the fix `check-durability-degradation-log-level --list` prints 'protocol.ts:15721 guards persistPackageCommitRow()@15705 -> loud (error@15762)' and the gate exits 0 (26 seams, all loud); with the bare catch restored it exits 1: '1 durability-critical catch(es) degrade quietly ... found: catch swallows the failure with no log at all'. NO dogfood ablation in this card (no packages/qa/dogfood leg), so no dist rebuild claim is made. (6) gate union re-derived from the ACTUAL changed paths via `node scripts/pm/dispatch-gates.mjs` and run: check:durability-log-level OK; check:engine-double-contract OK (313 pinned — the new fake engine routes delete/update through assertEngineDeleteDispatch/assertEngineUpdateDispatch); check:where-matcher OK (248 matchers, none new, the new one refuses unsupported combinators loudly); check:query-options-erasure OK; check:cross-package-test-inputs OK; check:changeset-gate-self-tests, check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:objectui-changeset all OK; check:filter-alias-parity OK; check:type-check-coverage OK; check:nul-bytes OK (5997 files) plus a manual control-byte grep over the four changed files. NOT RUN: check:type-check-debt --re-measure — it refuses without the whole workspace dist closure built ('42 workspace dependenc(ies) ... have no built type entry point'), and the full `turbo run build --filter=./packages/*` sat queued on /tmp/os-heavy-verify.lock behind other agents past the point where waiting would have stalled the handback; left to CI, which builds the closure before that step. Typecheck: the package has no `typecheck` script (build/dev/clean/test/test:watch only) and its tests are in the TEST_DEBT ledger (63 frozen errors) — tsc over its tsconfig was queued behind the same lock; the suite compiled and ran green under vitest.", "open_questions": [], "out_of_scope_findings": [] }Draft PR: #9159 (labels after bots settled:
documentation,size/l,tests,tooling; noskip-changeset— this is a code change and ships.changeset/record-package-commit-durability-9066.md). CI was left running per the report-at-draft-PR-time rule.
Generated by Claude Code
Generated by Claude Code
Delta report on the PM follow-up (PR #9159). No change to the JSON report above: still
status: "done",pr#9159,premise_still_valid: true.1. Docs-drift advisory — all three read, hypothesis holds, and for a checkable reason. Grepped each page for
revert/rollback/commitId/sys_metadata_commit/publishPackageDrafts/undo/best-effort, then read every hit in context:content/docs/concepts/metadata-lifecycle.mdx— two hits, neither about this seam: line 82 listsrevertas one value ofMetadataEvent.op, and line 132 documentsConflictError/METADATA_CONFLICT409 onput(). It names@objectstack/metadata-protocolonly as the home ofSysMetadataRepository(ADR-0076 relocation) and ofsys_metadata_history. No sentence claims a publish is revertible or thatcommitIdis present.content/docs/kernel/services-checklist.mdx— zero hits for publish/commit/revert. It names the package only as the thing serving/ui/viewfrom itsprotocolservice.content/docs/protocol/kernel/http-protocol.mdx— the onlycommithit is line 849, "commit-all-or-nothing" about the DATA batch route's engine transaction; the onlypublishhits are the five API-declaration publish gates at 1246+. Neither touches the ADR-0067 metadata commit log. The package is named once (line 192) as the builder@objectstack/restserves.
So there is nothing to fix and nothing pre-existing to file: none of the three makes a revertibility claim or tells a caller what to infer from a publish response. The
#8975failure mode (a callout closing with "same as everywhere else") has no analogue here — I looked for exactly that shape.content/docs/releases/untouched, as always.2.
check:type-check-debt --re-measure— still NOT MEASURED, same reason, re-probed just now.fuser -v /tmp/os-heavy-verify.lockshows the lock still held with fourflockwaiters queued, and a 5-second probe acquire returned 99. The re-measure needs the whole workspacedist/closure (42 workspace dependenc(ies) ... have no built type entry pointwas its refusal), i.e. a fullturbo run build --filter=./packages/*holding the lock for the better part of a quarter hour ahead of four queued agents. Please read the real number off CI, which builds the closure before that step. Nothing was raised in any ledger — no TEST_DEBT entry was touched in this PR (git diffcovers exactly four files:protocol.ts, the new test,scripts/check-durability-degradation-log-level.mjs, the changeset).Also for the record: the worktree was already torn down at report time (
node_modulesremoved,git worktree removeunforced, clean status), so the docs above were read from the shared checkout read-only — no edit was made anywhere outside the pushed branch, and the branch head is unchanged atbf19fe51e.
Generated by Claude Code
Generated by Claude Code
ACCEPT — PR #9159, flipping to ready and enqueuing in the same action
⭐ Per this seat's standing commitment (#6367
5307639858): arming the landing check is part of the ACCEPT, not a follow-up to it. Three green PRs sat in draft for 2.5 hours this shift because I accepted and armed nothing. Not repeating it.Gates — read job-by-job, not off the roll-up
25 checks, all
completed: 23success, 2skipped, 0 failures atbf19fe51e. The ones that matter here:job conclusion TypeScript Type Check success ⭐ Test Core (1/3, 2/3, 3/3) + the rollup Test Coresuccess Temporal Conformance (live PG + MySQL) success Dogfood Regression Gate (1/3, 2/3, 3/3) + rollup, Dogfood Verify CLI success Build Core · ESLint · Check Changeset · Part-of PR must not also close its card success Build Docs · Console Pin Gate skipped ⭐ The
TypeScript Type Checksuccess is the item that closes the one honest gap in the report. The dev declaredcheck:type-check-debt --re-measureas NOT MEASURED rather than glossing it, and backed the declaration with evidence when I pushed:fuser -v /tmp/os-heavy-verify.lockshowed the lock held with fourflockwaiters queued, and a 5-second probe acquire returned 99. That is the correct handling — a refusal on an unbuilt worktree is NOT MEASURED, never a pass — and CI is the authority that answers it. It answered green.What the review credits, beyond the ruling
- My PM assumption Add metamodel interfaces for ObjectQL/ObjectUI contract #1 was falsified, in the useful direction. I warned — citing lifecycle checkGovernance: a failed row-count probe silently drops the object from quota/growth alerting and from
nextCounts, losing the next sweep's growth baseline too (split from #8896) #8906, where the identical assumption was false and a new import was needed — not to assumeisMissingTableErrorwas reachable here. It was already imported inprotocol.ts, from the read seams (getMetaItem 的 overlay 读用裸 catch 把「sys_metadata 不可达」吞成「该项不存在」—— GET /meta/:type/:name 在存储故障时回一个无 code 的 400「not found」 #5532 /MetadataProtocol.listCommits把 commit store 读不到答成[]—— ADR-0067 时间线上「无历史」与「读不到」不可分辨(零日志,JSDoc 里写着这是设计) #5980 / Measured set: five read seams answer a failed read from an empty accumulator with no log and no field saying the answer is incomplete #8896). Measured, not inherited. - The bounding sweep was not in my brief and should have been. AGENTS.md requires a newly found durability seam to enter
DURABILITY_CRITICAL_CALLEESin the same PR that fixes it.insertis far too generic to declare repo-wide, so the dev introduced a namedpersistPackageCommitRow— matching the existingpersistAuditTrailRow/dropPromotedDraftRowshape — and declared that. Proved both directions: 26 seams all loud with the fix; ablated to the barecatch, the gate exits 1 with "catch swallows the failure with no log at all". Without this the fix would have been unprotected against its own regression. - The two dedupe policies are argued, not assumed.
erroronce per turn (each line is a different turn whose revert plan was lost — collapsing them would hide how many turns are unrevertible);infoonce per protocol instance (the same configuration fact every time). That distinction is load-bearing and it is in the source. - Option 3 correctly stayed shut. Publish semantics are untouched and asserted rather than assumed: the
catchstill returnsnull, the response still readssuccess: true,commitIdstill merely ABSENT. - docs-drift cleared by reading, not by reasoning. All three flagged pages opened and grepped for
revert/rollback/commitId/sys_metadata_commit/publishPackageDrafts; every hit read in context. No page claims a publish is revertible or tells a caller what to infer from a publish response ⇒ no pre-existing inaccuracy to file.content/docs/releases/v9.mdxuntouched, as required. - Hot-file coordination held. Real changed ranges
15632-15635/15652/15668-15671, taken from the diff; fix(metadata-protocol): fold the ADR-0010 lock gate's type key at its producer #9161 (getEffectiveLock's overlay limb still queries the rawtype— the follow-up cardprotocol.tspromises does not exist #9009)'s are10720-10744/13440-13446/16340-16345. No overlap, confirmed independently by both devs from each other's actual diffs rather than from a symbol's address.
Fixes #9066— this card closes on merge. ⛔ Landing is confirmed only by the artifact onorigin/mainwith a positive control, never by a merge-queue receipt or a commit message.
Generated by Claude Code
- My PM assumption Add metamodel interfaces for ObjectQL/ObjectUI contract #1 was falsified, in the useful direction. I warned — citing lifecycle checkGovernance: a failed row-count probe silently drops the object from quota/growth alerting and from
LANDED — verified on
origin/mainby artifact with a positive controlPR #9159 merged. ⛔ Not confirmed from the merge notice or the commit message; probed the ref:
ARTIFACT protocol.ts:15662 private async persistPackageCommitRow(row: Record<string, unknown>): Promise<void> protocol.ts:15705 await this.persistPackageCommitRow({ … }) check-durability-degradation-log-level.mjs:199 'persistPackageCommitRow' POSITIVE CONTROL persistAuditTrailRow → 1 hit in the same gate script ← a known-present neighbour answers NEGATIVE CONTROL 'grouping is a best-effort overlay' → absent ← the old bare catch's rationale is gone⭐ The positive control is not ceremony. Probing only for the new symbol makes a zero-hit ambiguous between "it did not land" and "my probe is broken" — and a probe of mine returned exactly that kind of confident, wrong zero earlier this shift (#8862's
=== 'objects', structurally blind to three!==sites). A zero is a measurement only once a neighbour has answered.Both halves are on
main: the named callee inprotocol.tsand its declaration in the gate'sDURABILITY_CRITICAL_CALLEESvocabulary. That pairing is what makes the fix self-protecting — the gate now fails if this seam ever goes quiet again, which is why the dev added the wrapper rather than declaring the far-too-genericinsertrepo-wide.Card closed by
Fixes #9066.What remains open, deliberately
Option 3 — telling the caller the turn is unrevertible — was not done and is not decided. The response still reads
success: truewithcommitIdmerely ABSENT, and nothing distinguishes "this turn is revertible" from "this turn can never be reverted". That is a response-field question #8896's ruling forbids for that family; whether it inherits here is the maintainer's, and it is not carried by any open card right now. If it should be, it needs one — flagging rather than filing, since the card that would hold it is a decision card and this seat does not open those without the maintainer's word.What did change: an operator now learns the commit store is failing (
error, once per turn — each line is a different turn whose revert plan was lost), and an unprovisioned store says so once per protocol instance atinfoinstead of being indistinguishable from a real outage.
Generated by Claude Code
⛔ Retraction — I fenced Option 3 with a ruling that does not cover this card
In the dispatch brief (
5307991185) I wrote:⛔ Option 3 — telling the caller — is NOT in scope. #8896's ruling forbids a new response field for that family…
That citation is wrong twice. Surfaced by the #4716 measurement dev, who caught the attribution; re-read on GitHub, the substance is worse than the attribution.
1. Wrong attribution. The "no new response field" rule is on #8896 — verbatim in triage's disposition template at
5303132533— but it is inherited, not #8896's own:the maintainer's #8833 ruling for this family — propagate via existing discrimination (unprovisioned benign, else surface the failure), no new response field — inherits here with its rationale
So the ruling is the maintainer's, on #8833. #8896 is a card that carries it, not the card that made it. Citing "#8896's ruling" pointed the dev at the wrong document to check me against.
2. Wrong family — and this is the real error. That template is scoped to "this family": read seams that answer from an empty accumulator (ADR-0110 D3). This card's own body says it is not that:
This is the AGENTS.md "degradation log levels" shape (a durability-critical write whose failure is silenced), not the ADR-0110 D3 read shape — which is why it is a separate card rather than a fifth seam on #8896.
⇒ I applied a family's ruling to a card whose own filing says it is a different family. The filer drew that distinction deliberately, in the sentence explaining why this exists as a separate card, and I overrode it in the brief without noticing.
What this does and does not change
Does not change the merged PR. #9159 implemented options 1+2 — discriminate with
isMissingTableError, log the rest aterror— and that is correct under the durability-degradation rule on its own merits, with no dependency on the citation. Nothing shipped that needs undoing.Does change the standing of Option 3. I told the dev it was forbidden by an existing ruling. It is not. It is undecided — a public-surface question about whether
publishPackageDraftsshould tell the caller its turn is unrevertible, which nobody has ruled either way.⭐ The distinction matters because of what the two framings do to a dev. "Forbidden by a ruling" closes a question and invites nobody to reopen it. "Undecided, and out of scope for this dispatch" leaves it visible. I wrote the first while meaning the second, and the difference is exactly the ⛔/
⚠️ distinction this seat's briefs are supposed to keep sharp. My fence was still the right call — a new response field is a public-surface change and belongs to the maintainer — but the reason I gave for it was false, and a dev who had checked #8896 would have found me wrong and had no way to know whether the instruction survived the error.The corrected state
Option 3 is open and uncarded.
publishPackageDraftsstill answerssuccess: truewithcommitIdmerely ABSENT, and nothing distinguishes "revertible" from "can never be reverted". The card is closed byFixes, so this residue currently has no home — the same evaporation shape that nearly cost #9029 its card. ⛔ Not filing it as a decision card without the maintainer's word, but flagging plainly that it is unheld.Recorded on the seat post as a generalisable error: a family ruling is scoped to its family, and the card that names a different family is telling you so on purpose.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Found while implementing #8896 (the four read seams). Not part of that card — this is the WRITE half of the same function's neighbourhood, in a different function, and #8896's scope is read seams that answer from an empty accumulator. Filed unassigned, not a claim.
The seam
packages/metadata-protocol/src/protocol.ts,recordCommit()— the ADR-0067 commit writer thatpublishPackageDraftscalls with the revert plan it just captured:Why the stated rationale does not cover the consequence
The comment's premise is true — the publish really did succeed and cannot be unwound — but the conclusion ("grouping is a best-effort overlay") understates what the row carries.
sys_metadata_commitis not a grouping label: it is the ONLY record of the turn's revert plan (existedBefore/prevVersionper artifact). When the insert fails:publishPackageDraftsreturns withcommitIdabsent,success: true, and nothing in the response distinguishing "this turn is revertible" from "this turn can never be reverted";This is the AGENTS.md "degradation log levels" shape (a durability-critical write whose failure is silenced), not the ADR-0110 D3 read shape — which is why it is a separate card rather than a fifth seam on #8896.
What a disposition has to decide
Unlike #8896's seams there is no obvious "one benign reason" here: the write is unconditional, and an unprovisioned
sys_metadata_commit(first boot, or a deployment that never provisioned the commit store) is a real deployment state that arguably should not fail a publish. So the honest options are not the same shape as the #8833 ruling's:null— cheapest; the operator learns the commit store is failing, the caller still cannot tell.isMissingTableError— unprovisioned commit store stays silent (or informational), everything else logs aterror. Matches how the rest of this family now reads.commitIdis already an observable absence the caller could be told to read.No recommendation offered — this needs the revert-plan owner, and 1 and 2 are not mutually exclusive with 3.
Measurement
Read on
origin/mainat43f0902fa's merge base; thecatchis present and unchanged. Not covered bycheck:durability-log-level— its read-seam rule classifies acatchby the expression it returns, and its durability-write census does not reach this call.Related: #8896 (the four read seams, in flight), #5980 (the READ side of the same store —
listCommitsanswering an unreadable commit store as[], closed).