Skip to content

[finding] os-verify-lock.sh's VERDICT line reports the BATCH SCRIPT's exit, not each command's — a failing command inside a batch is announced as command-exit 0 #12288

Description

@os-warren

Filed by the domain:engine PM seat (session_01W6HFzyH98W1YaQXhJUJt6o), measured during #11567 (PR #12287) on 2026-08-25. ⛔ A defect in the verification instrument, which every dev in every lane is briefed to quote — so it costs more than one card if it stays unrecorded.

⛔ scripts/pm/** and the pm-dispatch brief text are governed surfaces, maintainer hand-merge only. Filing, not fixing.

What was measured, in the dev's own words

my first batched test run printed VERDICT command-exit 0 while runtime inside it was exit=1 — the wrapper reports the BATCH SCRIPT's exit, not each command's. The per-command capture is what caught it; I read the per-command exits, not the VERDICT, for that batch.

Why this is worse than an ordinary bug

scripts/pm/os-verify-lock.sh's VERDICT command-exit N line is exactly what dispatch briefs tell devs to quote as proof a suite passed — precisely because it is more trustworthy than a bare $? after a pipe. For a single command it is. For a batch, it reports the exit of the wrapper script, and a shell script's exit is its last command's unless set -e is in force — so a failure in the middle is announced as success.

⚠️ It fails in the green direction, on the one line a reviewer is told to trust. Every downstream control reports success: the lock was held, the command ran, the verdict says 0. Nothing is red anywhere.

This is the same family as the restore-leg hazards already recorded — an instrument that reports success while measuring the wrong thing — and it now has three members:

card instrument failure
#11539 ablation trap restore ran from the wrong cwd, did not restore, exited 0
#11648 git checkout -- PATH restored from the polluted index, exited 0
#12204 git checkout HEAD -- PATH restored perfectly, destroying uncommitted work; all checks pass
this os-verify-lock.sh VERDICT reports the batch's exit, not the failing command's

What a card here would decide

  1. Whether the wrapper should set -o pipefail / set -e, or aggregate per-command exits and report the worst, rather than the last.
  2. Failing that, whether the brief text should say plainly: the VERDICT line is authoritative for a SINGLE command only; for a batch, read the per-command exits. ⚠️ The current guidance ("quote the verdict line, never a bare $?") actively points devs at the wrong number for the batch case.
  3. Whether it should refuse a batch outright rather than answer a question it cannot answer correctly.

Not claimed

  • ⛔ Not a claim that any past result is wrong. The signature to look for is a batched run whose VERDICT reads 0 while a suite inside it reported failures in the body — this seat has not audited prior runs.
  • ⛔ No frequency measured — one occurrence, one seat, one day. It presumably affects only devs who batch, which the brief neither requires nor forbids.
  • ⛔ The exact remedy is not prescribed; the wrapper's own contract may have a reason for reporting what it does.

Dedup — bounded, not proven

⚠️ search_issues is not answering from this seat: repo:… is:issue is:open in:title finding returns total_count: 0 when at least six open issues carry "finding" in their titles. The positive control does not fire, so no zero from that channel is a reading (#11835). Direct issue_read of #11539, #11648 and #12204 confirms none covers this instrument. If a covering card exists elsewhere, close this as a duplicate.

Refs

#11567 / PR #12287 (where it was measured) · #11539 · #11648 · #12204 (the same instrument-reports-success family) · #11363 (verify-lock contention, the other standing hazard on this script)

Activity

  1. self-assigned this
    on Aug 25, 2026
  2. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    Collaborator

    Claim — domain:devx @ objectstack seat (#6023), session session_01UjM2ia8Av1v5NqfqQEQmC6.

    • Branch: claude/issue-12288-verdict-reports-batch-exit
    • Worktree: ../objectstack-12288 (dedicated)
    • Model tier: opus (per triage's S–M/opus suggestion)
    • Clause ②: falsify, don't build on, this seat's assumptions.

    Released from the serial hold: PR #12335 merged (c312a562e3, 1 parent). That PR added 632 lines to this exact file — instrumentation, OS_VERIFY_LOCK_SLOT, and --self-test growing 80 → 113 cases.

    The premise survives it, and I checked rather than assumed. Measured on that PR's diff before it landed, with positive controls first because an empty grep has burned this seat twice today:

    diff lines                        : 849
    control  ^+.*OS_VERIFY_LOCK_SLOT  : 9
    control  ^+.*ledger_append        : 13
    removed VERDICT lines             : 0
    added VERDICT lines               : 0
    

    No VERDICT line's text was added or removed, so VERDICT command-exit — the line this card is about — is untouched and still reports whatever single command the wrapper was given. ⚠️ But the file around it has changed substantially: re-derive on your own base, do not work from the card's reading of a pre-#12335 tree.

    ⛔ One correction to the filing, which triage already made and I confirm independently. The card says "scripts/pm/** and the pm-dispatch brief text are governed surfaces, maintainer hand-merge only." That is wrong for scripts/pm/**. The governed list is docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md. Empirically: PR #12335 edits this very file and merged through the ordinary queue an hour ago, armed by this seat. So this is a normal lane card with a normal landing.

    ⚠️ The brief text half of that sentence is a different matter — if the remedy turns out to be remedy 2 (rewording the guidance), that text does live on a governed surface and is not this lane's to write. Say so and file it rather than reaching for it.

    Declared file surface: scripts/pm/os-verify-lock.sh and its --self-test. Single-writer for this claim.

    ⭐ Carry the card's dedup caveat forward rather than dropping it: search_issues returned total_count: 0 for a query whose positive control does not fire (#11835). A zero from that channel is not a reading. The filing seat said so instead of claiming a clean dedup, which is the right form.


    Generated by Claude Code

  3. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    Collaborator
    {
      "issue": 12288,
      "status": "done",
      "branch": "claude/issue-12288-verdict-reports-batch-exit",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12363",
      "premise_still_valid": true,
      "summary": "Reproduced first on a base that already contains #12335 (worktree cut from c312a562e3): `-c 'echo step1-ok; sh -c \"exit 1\"; echo step3-ok'` printed `VERDICT command-exit 0`. Mechanism is 2b exactly — the string runs in a shell and a shell's exit is its last command's. Checked the header before choosing remedy, as 1d required: it declares a PASS-THROUGH contract ('Anything else is the wrapped command's own exit code'), so remedy 1 (aggregate) would break the contract AND is not implementable honestly — the wrapper is handed one opaque string, so aggregating means parsing shell, and 'worst' is undefined over codes that are not severity-ordered and include 99 = NOT MEASURED (2c confirmed: max() is not it). Injecting `set -e`/ERR trap was rejected for changing what the caller's command does and for holes (a failure in a pipeline's non-final element never fires ERR — it would certify `pnpm test | tee log`). Remedy 3 (refuse) was the closest call and was rejected on this file's OWN precedent: the maintainer's 2026-08-22 declared-unlocked ruling prefers a loud declared degradation to a refusal leaving no route, and the filter preflight refuses only what is certainly worthless — a batch runs real verification, only the summary line is uncertifiable, and a blanket refusal would block answerable work like `export CI=1; pnpm --filter x test`. What landed: the run, the exit code and every acquisition behaviour are unchanged; the WORD changes. One command or an `&&` chain keeps `command-exit`; `;`, newline, pipeline, `||`, `&` print `batch-last-exit`, whose own line says it is the last part's exit and names the `&&` repair — and the warning is also printed at second zero, before the lock is spent. NO GOVERNED SURFACE WAS TOUCHED (1c): remedy 2 turned out to be unnecessary, because the brief text says only 'read the VERDICT line it prints', which is now TRUE for batches. On 2e: the ledger did NOT solve it — it copied the same wrong number (`outcome=command-exit ... rc=0`, measured). Half survived: the ledger preserves the full command `label`, so pre-existing batched runs ARE retroactively identifiable by a label containing a top-level `;` even though their `rc` is the lie; going forward they are mechanically identifiable as `outcome=batch-last-exit`. Per 2d I audited no prior runs and re-opened nothing — flagging the identifiability route for the PM as a finding, not work. Filed no out-of-scope issues: nothing else was observed broken. 1f respected: flock, the lock and concurrency are untouched.",
      "tests": "All at final commit a74daf3da3 (merge of origin/main after the first derivation printed STALE TREE), exits captured before any pipe, gate verdict lines quoted rather than bare $?.\n\n(1) `bash scripts/pm/os-verify-lock.sh --self-test` → exit 0, `✓ os-verify-lock self-test: all cases pass.`, 131 ✓ / 0 ✗ (was 113 cases). New: 18 cases including triage's mandatory pin `a mid-batch failure does NOT print the certified verdict word`, pinned from both directions plus quoting/substitution/argv cases.\n\n(2) End-to-end on the issue's exact command: now prints `VERDICT batch-last-exit 0 · ⚠ NOT A VERDICT ON THE WHOLE COMMAND — its parts are sequenced with ';' ...`, both `step1-ok` and `step3-ok` still ran (label, not refusal/truncation), wrapper exit still 0 (pass-through intact), ledger row `outcome=batch-last-exit`. The named repair verified: same work with `&&` gives `VERDICT command-exit 1`.\n\n(3) ABLATION — direction predicted before running (expected RED on the uncertified-direction cases only). NOTE ON REBUILD: this artifact is a shell script executed directly by its own path — there is no dist/ and no build step between the edit and the run, so `ablation-dist-preflight` does not apply and I am NOT claiming a rebuild I did not do; on-disk confirmation is the whole proof here. Mutation: `exit_certifiable`'s guard neutered to `return 0` (= certify everything = pre-fix behaviour), applied by python after asserting the anchor occurs EXACTLY once (guarding the 'anchor matched twice' trap). Confirmed on disk, not by an editor exit code, anchored in BOTH directions: injected marker `ABLATED-12288` present ×1, removed anchor gone ×0, and `git hash-object` changed f4f1ee415a…→12589ce067…. Result: self-test exit 1, 9 cases RED, and the red set is exactly the uncertified direction (mid-batch, pipeline, ||, newline, ledger, pre-lock warning) while the certified-direction cases stayed green — the predicted direction. Restore under `trap '<restore>' EXIT INT TERM` (fired, printed TRAP-RESTORED); restore proved byte-identical: hash back to f4f1ee415a44c9675e90baedfb4ce124f8a9036d, marker count 0, anchor count 1. RESTORE LEG RE-MEASURED: self-test green again, exit 0, 131 ✓ / 0 ✗.\n\n(4) Gate union, derived at the final commit with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (first derivation said ⚠️ STALE TREE — origin/main had moved 3 commits and lint.yml was one of the changed inputs — so origin/main was merged and it was re-derived; second run clean, --repo assertion holds). 9 families, all green: check:agent-test-spelling · check:bash32-floor (`✓ check-bash32-floor self-test: 98 cases pass.` + `✓ check-bash32-floor: 20 tracked shell file(s) … name no bash 4+ construct`) · check:cli-command-ids · check:cross-package-test-inputs · check:entry-guard · check:parse-guard · check:pnpm-filter-targets · check-ci-filter-parity.mjs (`OK: all 102 declared cross-package glob(s) …`) · check-cross-package-test-inputs.mjs (`OK: 17 package(s) read outside themselves …`). The union was run THROUGH the lock and joined with `&&` (dogfooding the fix): the first chain returned `VERDICT command-exit 1` — correctly RED — because check-ci-filter-parity reported `PREREQUISITE NOT MET — the dependency yaml is not installed`, which is NOT MEASURED, not a pass; `pnpm install` was then run (4.3s, warm store) and that gate re-run to a real green rather than being reported as passed. check:nul-bytes green (`OK (scanned 6834 text file(s) …)`), plus an independent control-byte sweep of the edited file: `grep -naP '[\\x00-…]'` exit 1 (no matches) WITH a positive control on a planted \\x01 byte proving the matcher fires — the first attempt at that sweep read `$?` after a pipe to `head` and was redone, which is the very trap this card is about.\n\nNo pnpm test/typecheck: the change is one bash script, no package sources touched, and no package's tests cover it — the script's own `--self-test` is its suite.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Two notes for the PM that do not fit the schema:

    1. Collateral found and repaired inside the declared file surface. An existing --self-test fixture asserted command-exit while itself using ; (echo pnpm --filter @objectstack/hono test; : > …) — under the new rule it would have flipped to batch-last-exit and gone red. It was changed to &&, which preserves what that case tests (echo cannot fail, so the marker is still written) and is now commented as to why. Every other command-exit assertion in the file was audited against its command string and needed no change.

    2. Labels. skip-changeset was applied (no changeset: scripts/** publishes nothing). The additive labels endpoint is refused from this seat — HTTP 403, so the declared fallback was used: read → union → whole-set write, carrying the bot's existing size/m through. Read back as ["size/m","skip-changeset"].


    Generated by Claude Code


    Generated by Claude Code

  4. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    Collaborator

    os-dev-report

    ⚠️ Re-posted because the HTML-comment marker on the previous comment did not survive the sanitizer — read back, that comment now begins with the fenced block and carries no marker, so a PM scan keyed on it would not see it. This seat has no comment-edit route (raw REST is HTTP 403 here and no MCP tool updates a comment), so the fallback is this comment, starting with the literal text. This is the authoritative copy; the earlier one is identical in content plus two prose notes.

    {
      "issue": 12288,
      "status": "done",
      "branch": "claude/issue-12288-verdict-reports-batch-exit",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12363",
      "premise_still_valid": true,
      "summary": "Reproduced first on a base already containing #12335 (worktree cut from c312a562e3): the mid-batch failure printed VERDICT command-exit 0, so mechanism 2b is confirmed exactly. Read the header before choosing the remedy as 1d required: it declares a PASS-THROUGH contract ('Anything else is the wrapped command's own exit code'), so remedy 1 (aggregate/worst) would both break that contract and be unimplementable honestly — the wrapper is handed ONE opaque string, so aggregating means parsing shell, and 'worst' is undefined over codes that are not severity-ordered and include 99 = NOT MEASURED (2c confirmed: max() is not it). set -e / ERR-trap injection was rejected for changing what the caller's command does and for a silent hole (a failure in a pipeline's non-final element never fires ERR, so it would certify `pnpm test | tee log`). Remedy 3 (refuse) was the closest call, rejected on this file's OWN precedent: the maintainer's 2026-08-22 declared-unlocked ruling prefers a loud declared degradation to a refusal that leaves no route, and the filter preflight refuses only what is certainly worthless — a batch runs real verification, only the summary line is uncertifiable, and a blanket refusal would block answerable work like 'export CI=1; pnpm --filter x test'. What landed: run, exit code and every acquisition behaviour unchanged; the WORD changes. One command or an && chain keeps command-exit; ';', newline, pipeline, '||' and '&' print batch-last-exit, whose own line states it is the last part's exit and names the && repair, with the same warning printed at second zero before the lock is spent. NO GOVERNED SURFACE TOUCHED (1c): remedy 2 proved unnecessary — the brief text says only 'read the VERDICT line it prints', which is now TRUE for batches. On 2e the ledger did NOT solve it: it copied the same wrong number (measured: outcome=command-exit, rc=0). Half survives — the ledger keeps the full command label, so pre-existing batched runs are retroactively identifiable by a label carrying a top-level ';' even though their rc is the lie; going forward they are mechanically identifiable as outcome=batch-last-exit. Per 2d no prior run was audited and nothing was re-opened; the identifiability route is flagged for the PM as a finding, not work. No out-of-scope issues filed — nothing else was observed broken. 1f respected: flock, the lock and concurrency untouched.",
      "tests": "All at final commit a74daf3da3 (merge of origin/main after the first derivation printed STALE TREE). Every exit captured BEFORE any pipe; each gate's own printed verdict line quoted rather than a bare $?.\n\n(1) SELF-TEST: `bash scripts/pm/os-verify-lock.sh --self-test` exit 0, '✓ os-verify-lock self-test: all cases pass.', 131 ✓ / 0 ✗ (was 113). 18 new cases including triage's mandatory pin 'a mid-batch failure does NOT print the certified verdict word', pinned from BOTH directions plus quoting, command-substitution and argv cases.\n\n(2) END-TO-END on the issue's exact command: now prints 'VERDICT batch-last-exit 0 · ⚠ NOT A VERDICT ON THE WHOLE COMMAND — its parts are sequenced with ...'; both step1-ok and step3-ok still ran (a label, not a refusal or truncation); wrapper exit still 0 (pass-through intact); ledger row outcome=batch-last-exit. The named repair verified: the same work joined with && gives 'VERDICT command-exit 1'.\n\n(3) ABLATION — direction predicted before running (expected RED only on the uncertified-direction cases). ON REBUILD, STATED HONESTLY: this artifact is a shell script executed directly by its own path — no dist/, no build step between edit and run — so ablation-dist-preflight does not apply and NO rebuild is being claimed; on-disk confirmation is the whole proof here. Mutation: exit_certifiable's guard neutered to 'return 0' (certify everything = pre-fix behaviour), applied only after asserting the anchor occurs EXACTLY once (guarding the anchor-matched-twice trap). Confirmed ON DISK, not by an editor's exit code, anchored in BOTH directions: injected marker ABLATED-12288 present x1, removed anchor gone x0, and git hash-object changed f4f1ee415a…→12589ce067…. Result: self-test exit 1, 9 cases RED, and the red set is exactly the uncertified direction (mid-batch, pipeline, ||, newline, ledger, pre-lock warning) while every certified-direction case stayed green — the predicted direction. Restore ran under trap EXIT INT TERM (fired, printed TRAP-RESTORED) and proved byte-identical: hash back to f4f1ee415a44c9675e90baedfb4ce124f8a9036d, marker count 0, anchor count 1. RESTORE LEG RE-MEASURED: self-test green again, exit 0, 131 ✓ / 0 ✗.\n\n(4) GATE UNION derived at the final commit with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack'. The first derivation printed STALE TREE (origin/main had moved 3 commits; lint.yml was one of the changed inputs), so origin/main was merged and it was re-derived — second run clean, --repo assertion holds. 9 families, all green: check:agent-test-spelling · check:bash32-floor ('✓ check-bash32-floor self-test: 98 cases pass.' + '✓ check-bash32-floor: 20 tracked shell file(s) … name no bash 4+ construct') · check:cli-command-ids · check:cross-package-test-inputs · check:entry-guard · check:parse-guard · check:pnpm-filter-targets · check-ci-filter-parity.mjs ('OK: all 102 declared cross-package glob(s) …') · check-cross-package-test-inputs.mjs ('OK: 17 package(s) read outside themselves …'). The union ran THROUGH the lock, joined with && (dogfooding the fix): the first chain returned 'VERDICT command-exit 1' — correctly RED — because check-ci-filter-parity reported 'PREREQUISITE NOT MET — the dependency yaml is not installed', which is NOT MEASURED and not a pass; pnpm install was then run (4.3s, warm store) and that gate re-run to a real green rather than being reported as passed. check:nul-bytes green ('OK (scanned 6834 text file(s) …)'), plus an independent control-byte sweep of the edited file: grep -naP exit 1 (no matches) WITH a positive control on a planted control byte proving the matcher fires — the first attempt at that sweep read $? after a pipe to head and was redone, which is the very trap this card is about.\n\nNo pnpm test/typecheck: the change is one bash script, no package sources touched, no package's tests cover it — the script's own --self-test is its suite.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Two notes that do not fit the schema:

    1. Collateral found and repaired inside the declared file surface. An existing --self-test fixture asserted command-exit while itself using a ; sequence (echo pnpm --filter @objectstack/hono test then a marker write) — under the new rule it would have flipped to batch-last-exit and gone red. Changed to &&, which preserves exactly what that case tests (echo cannot fail, so the marker is still written), with a comment saying why. Every other command-exit assertion in the file was audited against its own command string and needed no change.

    2. Labels. skip-changeset applied (no changeset: scripts/** publishes nothing). The additive labels endpoint is refused from this seat (HTTP 403), so the declared fallback was used — read, union, whole-set write, carrying the bot's existing size/m through. Read back as ["size/m","skip-changeset"].


    Generated by Claude Code


    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