Skip to content

recordCommit swallows a failed sys_metadata_commit write — the publish reports success and the turn is silently not revertible #9066

Description

@os-zhuang

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 that publishPackageDrafts calls with the revert plan it just captured:

await this.engine.insert('sys_metadata_commit', { … items: JSON.stringify(args.items), … });
return { commitId };
} catch {
    // Commit store unavailable (or insert raced) — the publish itself
    // already succeeded; grouping is a best-effort overlay on top.
    return null;
}

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_commit is not a grouping label: it is the ONLY record of the turn's revert plan (existedBefore / prevVersion per artifact). When the insert fails:

  • publishPackageDrafts returns with commitId absent, success: true, and nothing in the response distinguishing "this turn is revertible" from "this turn can never be reverted";
  • the artifacts are live, so a user who wants to undo the publish has no commit to revert and no statement that one was ever attempted;
  • nothing is logged, so an operator cannot tell that the commit store is failing. A store that is down stays down: every subsequent publish loses its revert plan the same silent way.

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:

  1. Log loudly and keep returning null — cheapest; the operator learns the commit store is failing, the caller still cannot tell.
  2. Discriminate with isMissingTableError — unprovisioned commit store stays silent (or informational), everything else logs at error. Matches how the rest of this family now reads.
  3. Tell the caller — surface the fact that the turn is not revertible. Note 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's ruling forbids a new response field for THAT family; whether that inherits here is a maintainer question, since the missing commitId is 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/main at 43f0902fa's merge base; the catch is present and unchanged. Not covered by check:durability-log-level — its read-seam rule classifies a catch by 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 — listCommits answering an unreadable commit store as [], closed).

Activity

  1. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Triage: 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 by isMissingTableError so 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 missing commitId is 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 the catch is 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

  2. self-assigned this
    on Aug 16, 2026
  3. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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, recordCommit only — stop and report on breach
    Container & model: mode:subagent, model: opus
    Serial constraints cleared: ⚠️ hot file. #9009 is dispatched into protocol.ts in 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 unprovisioned sys_metadata_commit (first boot, never-provisioned commit store) stays silent or informational; everything else logs at error with 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_decision exit, not a judgement call you make in the PR.

    ⛔ The publish must still succeed. The catch keeps returning null; 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 机制假设(须实测,鼓励证伪)

    1. isMissingTableError is reachable from metadata-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 from nextCounts, losing the next sweep's growth baseline too (split from #8896) #8906 the identical assumption was false: isMissingTableError had zero hits anywhere in lifecycle/, and a new import was required. Measure the import graph before writing.
    2. recordCommit's failure is invisible to check:durability-log-level. The card claims the gate's read-seam rule classifies a catch by 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.
    3. sys_metadata_commit is the only record of the revert plan. Confirm from the schema and from publishPackageDrafts' caller side that existedBefore/prevVersion live 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

  4. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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; no skip-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

  5. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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 lists revert as one value of MetadataEvent.op, and line 132 documents ConflictError / METADATA_CONFLICT 409 on put(). It names @objectstack/metadata-protocol only as the home of SysMetadataRepository (ADR-0076 relocation) and of sys_metadata_history. No sentence claims a publish is revertible or that commitId is present.
    • content/docs/kernel/services-checklist.mdx — zero hits for publish/commit/revert. It names the package only as the thing serving /ui/view from its protocol service.
    • content/docs/protocol/kernel/http-protocol.mdx — the only commit hit is line 849, "commit-all-or-nothing" about the DATA batch route's engine transaction; the only publish hits 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/rest serves.

    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 #8975 failure 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.lock shows the lock still held with four flock waiters queued, and a 5-second probe acquire returned 99. The re-measure needs the whole workspace dist/ closure (42 workspace dependenc(ies) ... have no built type entry point was its refusal), i.e. a full turbo 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 diff covers 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_modules removed, git worktree remove unforced, 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 at bf19fe51e.


    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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: 23 success, 2 skipped, 0 failures at bf19fe51e. The ones that matter here:

    job conclusion
    TypeScript Type Check success ⭐
    Test Core (1/3, 2/3, 3/3) + the rollup Test Core success
    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 Check success is the item that closes the one honest gap in the report. The dev declared check:type-check-debt --re-measure as NOT MEASURED rather than glossing it, and backed the declaration with evidence when I pushed: fuser -v /tmp/os-heavy-verify.lock showed the lock held with four flock waiters 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

    Fixes #9066 — this card closes on merge. ⛔ Landing is confirmed only by the artifact on origin/main with a positive control, never by a merge-queue receipt or a commit message.


    Generated by Claude Code

  7. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    LANDED — verified on origin/main by artifact with a positive control

    PR #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 in protocol.ts and its declaration in the gate's DURABILITY_CRITICAL_CALLEES vocabulary. 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-generic insert repo-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: true with commitId merely 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 at info instead of being indistinguishable from a real outage.


    Generated by Claude Code

  8. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    ⛔ 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 at error — 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 publishPackageDrafts should 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. publishPackageDrafts still answers success: true with commitId merely ABSENT, and nothing distinguishes "revertible" from "can never be reverted". The card is closed by Fixes, 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

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions