Skip to content

feat(gate-state): durable per-issue gate state on the issue substrate (#166) - #177

Merged
aaddrick merged 13 commits into
Batch_2026-07-27_225225from
issue-166-durable-per-issue-gate-state-on-the-issue-substrat
Jul 28, 2026
Merged

feat(gate-state): durable per-issue gate state on the issue substrate (#166)#177
aaddrick merged 13 commits into
Batch_2026-07-27_225225from
issue-166-durable-per-issue-gate-state-on-the-issue-substrat

Conversation

@aaddrick

Copy link
Copy Markdown
Owner

Closes #166

Summary

Adds a durable, GitHub-native substrate for per-issue contrarian/review gate state so it survives run boundaries, mirroring the existing CONSOLIDATION_* marker subsystem end to end. This tier is substrate-only: no consumer wiring yet.

  • Write: postGateState(ctx, boundary), a non-fatal stage modeled on cap-note-plan, posts a title-gated ## Gate State issue comment: one non-directive human summary line plus a fenced JSON payload ({schema, repo, issue, run, batch, epoch, boundary, gate_budgets, settled}) inside a <details> wrapper, closing with the canonical <!-- ticketmill <repo>#<issue> --> marker. Called at four boundaries: after the approach-gate loop, after the plan-gate loop (all four exits), once per pr-review iteration, and on pr-review-death (covering the process_pr resume path). Append-only, positional last-wins.
  • Read: fetchGateStateBlocks(issueNumbers, priorWorkByIssue), a jq-pinned, chunked (max 5 issues/call) read-only probe, wired at Select time via attachGateStateBlocks, which always computes the four new PREFLIGHT_SCHEMA gate-state fields fresh (never trusting an agent-supplied value). RUN_EPOCH is derived once via the existing pure deriveRunEpoch and threaded through for staleness checks.
  • Parse: pure functions above the TICKETMILL-TEST-HARNESS-SPLIT marker — buildGateStatePayload, buildGateStateComment, parseGateStateComment, parseGateStateProbeRow, isTrustedGateStateAuthor, deriveRunEpoch, gateStateEpochStale, selectGateState, diffGateStateIntent, attachGateStateBlocks — the agent only ever returns raw text, JS alone decides.
  • Self-validate: verifyGateState(results) runs once per Report phase (not 2N), chunked, comparing parseGateStateComment output against each result's gate_state_intent and logging one of six outcomes (match/mismatch/superseded/read-failed/post-failed/no-intent) — proving post → GitHub → read → parse round-trips in the same run, non-fatal end to end.
  • Docs: new "Durable per-issue gate state" section appended to docs/architecture/gate-hygiene.md covering comment shape rationale, write boundaries, the four-state found/absent/malformed/read-failed contract, trust-before-last-wins selection, and the self-validation sweep.

Key decisions

  • Fail-open is visible: found / genuinely-absent / read-failed are three distinguishable states; a read failure is always logged, never silently presented as absent (absent must be falsifiable against prior-work evidence).
  • JSON payload (not consolidation's flat key:value lines) because settled[] is an array of five-field objects with free text.
  • No wall-clock calls anywhere (scripts/lint-engine.js forbids Date.now()); RUN_EPOCH is derived once via the existing pure deriveRunEpoch from the date -u idiom already used elsewhere.
  • The STOP.tripped exit is deliberately left unwritten (recorded as an explicit decision, not an oversight).
  • Verification is split into its own stage whose prompt carries only the issue number and the pinned fetch command — never the payload — so the verifying agent cannot echo back what it was told.

Token usage (approximate, this issue only): 876318 output tokens

@aaddrick

Copy link
Copy Markdown
Owner Author

Spec Review (Iteration 1)

Verdict: approved

Acceptance criteria vs. diff

Criterion (issue #166) Status Evidence
Block written at both boundaries, re-read across a simulated resume Met (exceeded) Write boundaries expanded to four (approach, plan, pr-review-i<n>, pr-review-i<n>-aborted) through the evaluate/contrarian process, closing the exact resume gap the issue names (process_pr reviewer-death path). tests/gate-state-post.test.js simulates the process_pr resume and asserts exactly one pr-review-i1-aborted block.
Parser unit-tested above the split marker, no gh/git in the loop Met tests/gate-state.test.js, gate-state-read.test.js, gate-state-post.test.js, gate-state-verify.test.js drive pure functions/mocked agent responders only; only string assertions against prompt text reference gh, never execute it.
Found / absent / read-failed distinguishable, read-failed logs Met selectGateState four-state table + a fifth malformed case; falsifiable-absent rule cross-checked against prior-work evidence; distinct log lines for suspicious-absent vs. plain read-failed.
Malformed JSON / truncated block / wrong issue number fail open Met parseGateStateComment covers all three plus wrong-repo/wrong-schema/marker-not-last, never throws (unit-tested).
No-prior-block run is byte-identical to today Met No consumer wired; ctx.metrics/ctx.settled untouched.
Write is idempotent under re-run (last-wins) Met Append-only, positional last-wins, no wall clock (RUN_EPOCH sourced from existing probe now fields, never Date.now()lint-engine.js clean).
node --test stays green Met 718/718 passing locally on the worktree HEAD.
Dedicated test file; extend decision-records.test.js only if settled shape changed Met Four new tests/gate-state*.test.js files; settleDecision itself is unchanged, so no edit to decision-records.test.js was needed.
Docs: CHANGELOG.md, gate-hygiene.md (append, not create — file already existed), index.md row Met gate-hygiene.md +306 lines appended; index.md/AGENTS.md/CLAUDE.md row updated (byte-identical pair preserved); CHANGELOG.md/plugin.json correctly left untouched (owned by the release stage per profile.release).
Don't touch hash-frozen pipeline.md/metrics.md/failure-semantics.md Met Confirmed no diff against those three files.

Process note (non-blocking)

Plan-gate contrarian iteration 3 raised two majors and hit the contrarian cap before full resolution (see "Proceeding After Contrarian Cap (Plan)"):

  1. RUN_EPOCH ordering — resolved in practice: the shipped attachGateStateBlocks no longer calls selectGateState at all (a deviation from the iteration-2 plan), so the only place RUN_EPOCH feeds selectGateState is the Select-time diagnostic log, where a null epoch is documented as deliberate and harmless (state doesn't depend on it, and nothing consumes stale yet).
  2. claim_authors fallback has no producer — confirmed still true at HEAD. isTrustedGateStateAuthor's claimAuthors fallback path is real code, but the pinned jq (gateStateProbeCommandLine) only ever selects ## Gate State blocks, never claim comments, so every production call site passes claim_authors: []. gate-hygiene.md describes the fallback as if it functions for installation-token deployments; in the shipped code it's currently dead. Impact today is zero (this repo's PAT-based self_login resolves fine, and the failure direction is safe — an unresolved trust check degrades a block to found (untrusted author), never to silently-trusted), so this doesn't block a substrate-only, no-consumer issue. Worth a follow-up: either wire a real producer (extend the pinned jq with a second claim-comment projection, as iteration 3 suggested) or drop the fallback and correct the docs.

Scope check: all changed files (workflows/ticketmill.js + lockstep copy, four new test files, pr-review-gate.test.js updates, gate-hygiene.md/index.md/AGENTS.md/CLAUDE.md) are gate-state-related; no unrelated features found. .claude/workflows/ticketmill.js lockstep copy verified byte-identical to workflows/ticketmill.js, and node scripts/lint-engine.js reports clean.

@aaddrick

Copy link
Copy Markdown
Owner Author

Code Review (Iteration 1)

Verdict: changes requested.

Validation baseline is clean: node --check workflows/ticketmill.js, node scripts/lint-engine.js (clean, 2 lockstep pairs in sync), and node --test (718/718 pass). The two engine copies are byte-identical. No sandbox violations (no Date.now(), no Math.random(), no argless new Date(), no Node API in the engine), no agentType in any agent() call, no weakened incident machinery, and the new stages correctly route through stageOpts('probe') / M.probe rather than silently defaulting to sonnet. Prompt/schema coherence checks out on all three new schemas.

The findings below are in the state machine itself, not in the plumbing.


1. BLOCKING — total counts all comments, so absent is unreachable and healthy reads report read-failed

workflows/ticketmill.js:1374 (gateStateProbeCommandLine) pins:

--jq '{total: (.comments|length), blocks: [.comments[] | select(.body | startswith("## Gate State")) | {...}] | .[-3:]}'

total is the count of every comment on the issue. blocks is the filtered gate-state list. But selectGateState at :1312 treats the combination as corruption:

if (blocks.length === 0) {
  if (total > 0) return Object.assign({ state: 'read-failed' }, EMPTY)

The code comment justifies this as "the probe says comments exist but produced none" — but the two counts are not measuring the same thing, so the premise is false. Any issue carrying a single ordinary comment (a human reply, or one of ticketmill's own decision/trail comments, which this pipeline posts on every issue it touches) and no gate-state block reports read-failed.

Confirmed against the real control flow via tests/harness.js — a healthy read of a fresh issue with four ordinary comments and zero gate-state blocks:

gate-state #55: read-failed

Consequences:

  • absent is reachable only for an issue with literally zero comments — which, after the first boundary post, no processed issue ever is.
  • The falsifiable-absent cross-check at :4597 (parsed.total === 0 && hasGateStatePriorWork(pw)) and its distinct absent (unexpected: PR #N open) log line are also nearly unreachable, because total is almost never 0.
  • This inverts issue Durable per-issue gate state on the issue (substrate) #166's headline constraint — found / absent / read-failed must be distinguishable. They are not; read-failed swallows absent for the common case.

The fail-open direction is safe, so this is not a data-corruption risk. It is blocking because the substrate ships a state machine that cannot report the state a future consumer needs, and the shipped docs describe behavior the code does not have.

Fix direction: have jq count only gate-state comments into total (e.g. total: ([.comments[] | select(.body | startswith("## Gate State"))] | length)), so total > 0 with an empty .[-3:] slice really is structurally impossible except under truncation. If the all-comments count is still wanted as corroboration, carry it under a separate key and never feed it to the blocks.length === 0 contradiction test.

2. MAJOR — superseded is unreachable in production; the benign case it exists for reports as mismatch

RUN_EPOCH is assigned exactly once per run (:8072) and postGateState stamps epoch: RUN_EPOCH (:4889) into every payload. So every boundary in a single run writes the identical epoch. diffGateStateIntent (:1355) gates supersession on actual.epoch > intent.epoch, which same-run writes can never satisfy:

// two boundaries, same run, as postGateState actually builds them
diffGateStateIntent(planPayload, prReviewPayload)  // -> 'mismatch'

The documented benign scenario — "a later pr-review iteration's write landing after an earlier iteration's intent snapshot" — is exactly what gets reported as real corruption. It is reachable: if a post lands on GitHub but the stage returns null or posted:false (STOP tripped mid-flight, budget exhaustion, schema miss), ctx.gate_state_intent stays at the earlier boundary while GitHub holds the later block.

The unit tests pass only because they hand-craft epoch: 1000 / epoch: 2000 fixtures within run-1 (tests/gate-state.test.js:425, tests/gate-state-verify.test.js:110-111) — a shape the engine has no way to produce.

Fix direction: add a monotonic per-run write sequence to the payload (a module-level counter incremented in postGateState — no clock needed) and order same-run supersession on that, not on epoch. Then update the fixtures to the shape production actually emits.

Related, same root cause: because RUN_EPOCH is the run's Select-time anchor rather than the write time, gateStateEpochStale measures a block's age from when its run started, not when it was written. On a long run that reads younger than it should. Log-only at this tier, but worth a comment if not fixed.

3. MAJOR — a queried issue dropped from a live chunk's rows logs as absent, not read-failed

fetchGateStateBlocks synthesizes deadGateStateChunkRows stubs only when a chunk's agent call dies. GATE_STATE_PROBE_SCHEMA cannot enforce one row per issue, so a schema-valid response that simply omits an issue leaves rowsByIssue[n] undefined, and the diagnostic loop at :4594 falls back to {}:

const rowsArg = row ? Object.assign({ exit_ok: row.exit_ok }, parsed) : {}

selectGateState({}) sees ok/exit_ok undefined, blocks: [], total: 0absent. Confirmed empirically (agent returns a valid rows array covering issue 1 but not issue 2):

gate-state #1: absent
gate-state #2: absent      <- never read at all

attachGateStateBlocks handles the same case correctly (gate_state_read_ok: false), so the stored substrate is right and only the log lies — but the log is this tier's only observable, and "read failed presenting as absent" is the precise failure mode the issue forbids.

Fix direction: either change the fallback to { ok: false }, or (better, since it also fixes the rowsByIssue/log disagreement) backfill stub rows for any queried issue missing from a live chunk's response, the same way dead chunks are handled.

4. MINOR — verifyGateState's comment contradicts diffGateStateIntent

:4978 says superseded means "a concurrent unclaimed run's later post ... is never reported as corruption." diffGateStateIntent requires intent.run === actual.run and its own comment (:1348) explicitly classifies a different run's write as mismatch. Two comments in the same diff describing opposite behavior. Drop the concurrent-run clause.

5. MINOR — docs/architecture/gate-hygiene.md:537 understates the absent predicate

The bullet reads "zero gate-state comments, and nothing else about the issue is evidence prior work happened." The code additionally requires total === 0. The prose two paragraphs down does say "zero blocks and zero total comments," so the page contradicts itself. Resolved for free by fixing finding 1; otherwise reconcile the bullet.

6. MINOR — a failed self-validation never reaches the batch PR

verifyGateState's mismatch and read-failed outcomes only call log(). Nothing is pushed to VERIFY_SKIPS, so a self-validation that proved nothing (or that found a lost write) is invisible in the batch PR's Verification Gaps section — the human's only window into what wasn't verified. Issue #166 asked only for a log, so this is a judgment call rather than a spec miss, but mismatch in particular looks like it belongs in VERIFY_SKIPS.

7. MINOR — four engine-only fields added to an agent output schema

PREFLIGHT_SCHEMA:520-528 now advertises gate_state_blocks / gate_state_read_ok / gate_state_total_comments / gate_state_trust to the preflight agent, with no description keys explaining them. attachGateStateBlocks clobbers whatever arrives, so this is harmless, but it departs from the attachEngineOwnedIntentional precedent the code cites — engine_owned_intentional is attached post-hoc and is deliberately not in PREFLIGHT_SCHEMA. The issue text did ask for a schema field, so noting only; consider dropping them from the agent-facing schema.


Not flagged, deliberately: the missing CHANGELOG.md entry and plugin.json bump (batch-level, owned by the gated Report-phase release stage), and the approach itself (contrarian's gate, upstream).

aaddrick added a commit that referenced this pull request Jul 28, 2026
…ill missing probe rows

Code review on PR #177 found four state-machine defects in the gate-state
substrate:

- gateStateProbeCommandLine's jq counted ALL comments into `total`, so any
  issue with one ordinary comment and zero gate-state blocks reported
  read-failed instead of absent -- the common case, not an edge case. `total`
  now runs through the same title-gated filter `blocks` uses.
- diffGateStateIntent ordered same-run supersession on `epoch`, but RUN_EPOCH
  is assigned once per run and is identical on every boundary a run posts, so
  'superseded' was unreachable in production. Added GATE_STATE_WRITE_SEQ, a
  monotonic per-run write counter embedded on the payload as `write_seq`, and
  moved ordering onto it.
- fetchGateStateBlocks' diagnostic log misread a live chunk's response that
  simply omitted a queried issue as absent rather than read-failed. Missing
  issues are now backfilled with the same read-failed stub a dead chunk's
  issues get.
- verifyGateState's mismatch/read-failed outcomes only logged; they now also
  push a VERIFY_SKIPS entry so a failed self-validation reaches the batch
  PR's Verification Gaps section.

Also fixes a stale doc comment (verifyGateState claimed a concurrent run's
write could report 'superseded'; diffGateStateIntent's own run check
forbids that) and reconciles gate-hygiene.md's `total` wording.

Validation: node --check, lint-engine (clean, 2 lockstep pairs in sync),
node --test (723/723 pass, 5 new).
@aaddrick

Copy link
Copy Markdown
Owner Author

PR Review Fix (iteration 1)

Commit: e88650d

Addressed all four code-review findings from the "## Code Review (Iteration 1)" comment:

  1. BLOCKINGgateStateProbeCommandLine's jq counted every comment into total, not just gate-state ones, making absent unreachable for any issue with an ordinary comment. total now runs through the same title-gated filter blocks uses.
  2. MAJORdiffGateStateIntent ordered same-run supersession on epoch, but RUN_EPOCH is identical across every boundary a run posts, so superseded was unreachable in production. Added GATE_STATE_WRITE_SEQ, a monotonic per-run write counter embedded on the payload as write_seq, and moved ordering onto it.
  3. MAJOR — a live probe chunk that omitted a queried issue from its response logged that issue as absent instead of read-failed. Missing issues are now backfilled with the same stub a dead chunk's issues get.
  4. MINORverifyGateState's mismatch/read-failed outcomes now push a VERIFY_SKIPS entry (batch PR Verification Gaps), the contradictory superseded doc comment is corrected, and gate-hygiene.md's total wording is reconciled.

Validation: node --check, lint-engine (clean, 2 lockstep pairs in sync), node --test (723/723 pass, 5 new tests covering the fixes).

@aaddrick

Copy link
Copy Markdown
Owner Author

Spec Review (Iteration 2)

Verdict: approved

Re-reviewed after the code-review fix commit (e88650d, "fix jq total, order supersession on write_seq, backfill missing probe rows"). Iteration 1's spec review already found the substrate met every acceptance criterion in issue #166; this iteration confirms the fix commit tightens that conformance rather than drifting from it, and finds nothing new to flag.

What changed since iteration 1's approval

Code-review finding (iteration 1) Issue #166 criterion it touches Fix verified
BLOCKING: jq counted all comments into total, making absent unreachable for any issue with an ordinary comment "Found, absent, and read-failed are distinguishable" gateStateProbeCommandLine's jq now filters total through the same title-gated select(...) as blocks; absent is reachable again on the common case
MAJOR: epoch-based supersession ordering was identical across every boundary in a run, making superseded unreachable "The write is idempotent enough that a re-run does not corrupt last-wins reads" New GATE_STATE_WRITE_SEQ monotonic per-run counter (no wall clock) embedded as write_seq; diffGateStateIntent now orders on it
MAJOR: a live probe chunk silently omitting a queried issue logged absent instead of read-failed "read-failed logs" / distinguishability Missing issues are now backfilled with the same read-failed stub a dead chunk gets, before the diagnostic loop runs
MINOR: verifyGateState mismatch/read-failed only logged, invisible to the batch PR Self-validation ("include a same-run read-back that logs a mismatch") mismatch/read-failed now also push a VERIFY_SKIPS entry
MINOR: contradictory doc comment on superseded; gate-hygiene.md total wording Documentation target (gate-hygiene.md) Both reconciled

Verification

  • node --check workflows/ticketmill.js clean.
  • node scripts/lint-engine.js: clean, 2 lockstep pairs in sync (.claude/workflows/ticketmill.js byte-identical to workflows/ticketmill.js).
  • node --test: 723/723 passing (5 new tests added in the fix commit covering exactly these four defects).
  • Diff scope for e88650d: workflows/ticketmill.js + lockstep copy, docs/architecture/gate-hygiene.md, three gate-state test files. No unrelated files, no touch to hash-frozen pipeline.md/metrics.md/failure-semantics.md.

Acceptance criteria (issue #166) — unchanged from iteration 1, re-confirmed at HEAD

All criteria previously marked "met" in the iteration-1 review remain met; none regressed. The four fixes above make the found/absent/read-failed distinguishability and idempotent-write criteria more precisely satisfied than at iteration 1, not less. No consumer wiring was added (still out of scope per the issue), no CHANGELOG.md/plugin.json edits (batch-level, correctly deferred to the release stage).

Scope check

No scope creep. The fix commit is narrowly targeted at the four code-review findings; nothing beyond that landed.

Consistency with iteration 1

This does not reverse the iteration-1 spec approval — it reconfirms it. The two still-open non-blocking notes from iteration 1 (RUN_EPOCH no longer feeding selectGateState's decision, and the unwired claim_authors fallback) are unchanged and remain non-blocking for a substrate-only issue.

@aaddrick

Copy link
Copy Markdown
Owner Author

Code Review (Iteration 2)

Verdict: changes requested.

Validation baseline is clean at e88650d: node --check workflows/ticketmill.js, node scripts/lint-engine.js (clean, 2 lockstep pairs in sync), bash -n scripts/setup-worktree.sh, both manifests parse, node --test 723/723, tests/setup-worktree.test.sh 32/32. The two engine copies are byte-identical. No sandbox violations in the diff (the four Date.now()/Math.random() hits are all comment text explaining why they are not used), no agentType, no weakened incident machinery.

Iteration-1 findings: all four re-verified fixed

# Finding Status Evidence
1 (blocking) jq total counted all comments Fixed gateStateProbeCommandLine() now builds one titleFilter and reuses it for both total and blocks. Ran the emitted filter through real jq: a fixture with 1 human comment + 4 gate-state comments returns total:4 with a 3-block slice; {"comments":[]} returns total:0. Through the harness, a healthy read of an issue with ordinary comments and no gate-state block now selects absent, not read-failed.
2 (major) superseded unreachable (epoch ties) Fixed GATE_STATE_WRITE_SEQ + write_seq on the payload; diffGateStateIntent orders on it. Verified: same-run write_seq 1 -> 2 gives superseded, 2 -> 1 gives mismatch, and a payload with no write_seq gives mismatch (fail-safe). Per-issue monotonicity holds because a single issue's boundaries post sequentially in its own await chain, so cross-issue concurrency can't invert it.
3 (major) live chunk omitting an issue Partially fixed — see below rowsByIssue backfill is correct ({raw:'', exit_ok:false} for any queried issue a live chunk omitted). The log, which was the actual subject of the finding, still mislabels part of this case.
4 (minor) contradictory superseded comment; VERIFY_SKIPS Fixed Comment corrected; VERIFY_SKIPS.push added for mismatch/read-failed at workflows/ticketmill.js:5112. Confirmed the sweep at :8607 runs before VERIFY_SKIPS is rendered into the batch PR body at :8768, so the entries actually reach Verification Gaps.

Finding 7 from iteration 1 (the four engine-only fields in PREFLIGHT_SCHEMA) was raised as noting-only; not re-flagged.


1. MAJOR — every hard read failure on an issue with prior-work evidence logs as absent (unexpected: ...)

workflows/ticketmill.js:4658-4663:

const sel = selectGateState(rowsArg, {...}, pw)
if (sel.state === 'read-failed' && parsed.blocks.length === 0 && parsed.total === 0 && hasGateStatePriorWork(pw)) {
  ...
  log('gate-state #' + n + ': absent (unexpected: ' + why + ')')
}

The branch is meant to fire only on the falsifiable-absent case: the read succeeded, returned zero blocks and total: 0, and hasGateStatePriorWork contradicted that. But it never checks that the read succeeded. Every hard read failure also produces blocks.length === 0 and total === 0parseGateStateProbeRow returns {ok:false, total:0, blocks:[]} on any failure — so on any issue with prior-work evidence, all of these print the same "absent" line.

Confirmed through the real control flow (tests/harness.js, fetchGateStateBlocks), five scenarios, pw = {pr_number: 99, resume_point: 'review'}:

--- live chunk omits #2 (finding 3's exact scenario)
gate-state #2: absent (unexpected: PR #99 open)
--- whole chunk dead (agent throws)
gate-state #2: absent (unexpected: PR #99 open)
--- gh exited non-zero for #2
gate-state #2: absent (unexpected: PR #99 open)
--- truncated jq stdout for #2
gate-state #2: absent (unexpected: PR #99 open)
--- clean read, zero blocks (the case the branch is FOR)
gate-state #2: absent (unexpected: PR #99 open)

Five different states, one indistinguishable line. Two consequences:

  • Finding 3 is not closed where it mattered. The stored rowsByIssue stub is now right, but iteration 1's fix direction was chosen because "the log is this tier's only observable, and 'read failed presenting as absent' is the precise failure mode the issue forbids." A queried-but-unanswered issue that also has an open PR still logs absent.
  • This is the production-dominant path, not an edge case. The whole point of gate state is resume continuity, and every issue on a resume has pr_number set or resume_point !== 'implement' — i.e. hasGateStatePriorWork(pw) is true for exactly the issues this substrate exists to serve. On a resumed run with a dead probe chunk, every issue in that chunk prints absent (unexpected: ...).
  • The (unexpected: ...) annotation also carries no information, since it fires for both the suspicious case and every ordinary read failure.

The correct predicate already exists eleven hundred lines up: attachGateStateBlocks:1468 computes const readOk = row.exit_ok === true && parsed.ok === true. The two halves of the same join disagree about what "the read succeeded" means — the same rowsByIssue/log disagreement iteration 1 named.

The two tests added for finding 3 (tests/gate-state-read.test.js:172 and :193) both pass {} / no prior-work for the affected issue, so neither exercises the read-failure + prior-work combination that flips the wording.

Fix direction: gate the branch on a successful read, e.g.

const readOk = row.exit_ok === true && parsed.ok === true
if (sel.state === 'read-failed' && readOk && parsed.blocks.length === 0 && parsed.total === 0 && hasGateStatePriorWork(pw)) {

so only a genuine falsifiable-absent prints the suspicious line and every read failure falls through to the plain gate-state #N: read-failed. Add prior-work-bearing variants of the existing dead-chunk, omitted-row, non-zero-exit, and truncated-stdout tests, which is what would have caught this.

2. MINOR — the verify sweep's call-site comment is stale after the VERIFY_SKIPS fix

workflows/ticketmill.js:8598-8600 still describes the sweep as "Advisory only (log lines, never a result mutation)". It now also pushes to VERIFY_SKIPS, which is exactly the non-log-only side effect that comment rules out. The function's own doc comment at :5019 was updated; this one was missed. Drop "(log lines" or say "log lines plus a VERIFY_SKIPS entry on mismatch/read-failed".

3. NIT — one unwrapped prose line

docs/architecture/gate-hygiene.md:588 is 115 characters where the surrounding prose wraps near 72. Cosmetic only, no factual drift.


Not flagged, deliberately: the missing CHANGELOG.md entry and plugin.json bump (batch-level, owned by the gated Report-phase release stage), and the approach itself (contrarian's gate, upstream). Also checked and clean: gate_state_blocks carries attacker-influenceable raw comment bodies, but it is never interpolated into any agent prompt (proposeConsolidation and the plan projection both take only issue/title/resume_point/pr/reason), so the new field introduces no injection surface.

aaddrick added a commit that referenced this pull request Jul 28, 2026
…sful read

Every hard read failure (dead chunk, non-zero gh exit, truncated stdout)
also produced blocks.length===0 and total===0, so it printed the same
"absent (unexpected: ...)" line as a genuine falsifiable-absent read. On
a resume, where hasGateStatePriorWork is true for exactly the issues
this substrate serves, that made read failures indistinguishable from
suspicious absences.

Add the readOk = row.exit_ok === true && parsed.ok === true guard,
mirroring attachGateStateBlocks' own definition, so only a genuine
successful-but-empty read prints the suspicious line and every read
failure falls through to a plain "read-failed". Add prior-work-bearing
test variants for the dead-chunk, omitted-row, non-zero-exit, and
truncated-stdout cases. Also fix the stale "advisory only (log lines,
never a result mutation)" call-site comment on the verify sweep (it now
also pushes to VERIFY_SKIPS) and rewrap one over-length prose line in
gate-hygiene.md.

Addresses PR #177 code review iteration 2 (issue #166).
@aaddrick

Copy link
Copy Markdown
Owner Author

PR Review Fix (iteration 2)

Commit: 480b01d

  • MAJOR: gated the "absent (unexpected: ...)" log line on an actual successful read (readOk = row.exit_ok === true && parsed.ok === true, mirroring attachGateStateBlocks) so a dead chunk, non-zero gh exit, or truncated stdout with prior-work evidence now falls through to plain read-failed instead of the suspicious-absent line. Added four prior-work-bearing test variants covering each read-failure mode.
  • MINOR: fixed the stale "advisory only (log lines, never a result mutation)" call-site comment on the verify sweep — it now notes the VERIFY_SKIPS push added in iteration 1's fix.
  • NIT: rewrapped the over-length prose line in docs/architecture/gate-hygiene.md:588.

Verified: node --check, node scripts/lint-engine.js (clean, copies re-synced and byte-identical), node --test 727/727 (723 + 4 new).

@aaddrick

Copy link
Copy Markdown
Owner Author

Spec Review (Iteration 3)

Verdict: approved

Re-reviewed after the code-review fix commit (480b01d, "gate the suspicious-absent log on an actually successful read"). Iterations 1 and 2 already confirmed the substrate meets every acceptance criterion in issue #166; this iteration confirms the fix commit closes iteration 2's remaining findings without touching scope.

Iteration-2 code-review findings vs. this commit

Finding (iteration 2) Issue #166 criterion it touches Fix verified
MAJOR: every hard read failure (dead chunk, non-zero gh exit, truncated stdout) with prior-work evidence logged the same "absent (unexpected: ...)" line as a genuine falsifiable-absent read "Found, absent, and read-failed are distinguishable, and read-failed logs" fetchGateStateBlocks now computes readOk = row.exit_ok === true && parsed.ok === true (mirroring attachGateStateBlocks's own definition at :1467) and gates the suspicious-absent branch on it. Four new prior-work-bearing test variants (dead-chunk, non-zero-exit, omitted-row, truncated-stdout) in tests/gate-state-read.test.js each assert the log line contains read-failed and never absent.
MINOR: stale "advisory only (log lines, never a result mutation)" call-site comment after VERIFY_SKIPS was wired in iteration 1 Documentation accuracy (self-validation contract) Comment updated to note the VERIFY_SKIPS entry on mismatch/read-failed.
NIT: one over-length prose line in gate-hygiene.md:588 Documentation target (gate-hygiene.md) Rewrapped to match surrounding line widths (verified: 72 chars, in line with neighboring 61-75 char lines).

Verification

  • node --check workflows/ticketmill.js and .claude/workflows/ticketmill.js: clean.
  • node scripts/lint-engine.js: clean, 2 lockstep pairs in sync (byte-identical, confirmed via diff).
  • node --test: 727/727 passing (4 new tests added in this commit, all green).
  • Diff scope for 480b01d: workflows/ticketmill.js + lockstep copy, docs/architecture/gate-hygiene.md, tests/gate-state-read.test.js. No unrelated files, no touch to hash-frozen pipeline.md/metrics.md/failure-semantics.md, no CHANGELOG.md/plugin.json edits (correctly deferred to the release stage).

Acceptance criteria (issue #166) — unchanged from iterations 1-2, re-confirmed at HEAD

All criteria remain met; none regressed. The read/absent distinguishability criterion is now precisely satisfied at the log level as well as the stored-field level (iteration 2's fix handled the field, this commit handles the log — the same disagreement iteration 1 originally named).

Scope check

No scope creep. The commit is narrowly targeted at the three iteration-2 findings; nothing beyond that landed.

Consistency with iterations 1-2

This does not reverse either prior spec approval — it reconfirms them. The two still-open non-blocking notes from iteration 1 (RUN_EPOCH no longer feeding selectGateState's decision, and the unwired claim_authors fallback) are unchanged and remain non-blocking for a substrate-only issue.

@aaddrick

Copy link
Copy Markdown
Owner Author

Code Review (Iteration 3)

Verdict: approved.

Validation baseline is clean at 480b01d: node --check workflows/ticketmill.js, node scripts/lint-engine.js (clean, 2 lockstep pairs in sync), bash -n scripts/setup-worktree.sh, both manifests parse, node --test 727/727, tests/setup-worktree.test.sh 32/32. The two engine copies are byte-identical (verified with a direct diff, not just the lint). No sandbox violations in the diff (every Date.now()/Math.random()/new Date() hit is comment text explaining why they are not used), no agentType in any agent() call, no weakened incident machinery, no profile-shape change.

Iteration-2 findings: all three re-verified fixed

# Finding Status Evidence
1 (major) every hard read failure with prior-work evidence logged as absent (unexpected: ...) Fixed fetchGateStateBlocks:4670 now gates the branch on readOk = row.exit_ok === true && parsed.ok === true, the same predicate attachGateStateBlocks:1468 uses, so the log and the stored preflight fields can no longer disagree. Traced the four read-failure paths by hand: a dead chunk and an omitted row both arrive as the {raw:'', exit_ok:false} stub (exit_ok false), a non-zero gh exit sets exit_ok:false, truncated stdout sets parsed.ok:false — all four now fall through to the plain read-failed line.
The branch stays reachable for the case it exists for, which is the thing a readOk gate could easily have killed: with exit_ok:true and parsed.ok:true, selectGateState:1344's prior-work cross-check is what returns read-failed, so a genuine successful-but-empty read on an issue with an open PR still prints the suspicious wording. tests/gate-state-read.test.js:297 asserts exactly that (unexpected + PR #123 present), alongside the four new prior-work-bearing negative variants at :154/:169/:223/:255. Positive and negative sides are both pinned, so a future regression in either direction turns a test red.
2 (minor) stale "advisory only (log lines, never a result mutation)" call-site comment Fixed :8607-8610 now reads "log lines, plus a VERIFY_SKIPS entry on mismatch/read-failed — see :5122". The :5122 cross-reference is accurate (that is the VERIFY_SKIPS.push line), and the sweep at :8614 still runs before the batch-PR body renders VERIFY_SKIPS at :8779.
3 (nit) over-length prose line at gate-hygiene.md:588 Fixed Rewrapped. The only remaining >90-char lines in the file (:756, :776) are pre-existing from #169's provenance section and are unbreakable file paths.

Independent re-check at HEAD

Beyond the fix commit, I re-walked the parts of the substrate a second fix pass could plausibly have destabilized:

  • State-machine reachability. All four selectGateState states are reachable with the shipped jq: total and blocks run through one shared titleFilter, so total > 0 && blocks.length === 0 is now only a truncation shape (a non-empty filtered list can never produce an empty .[-3:] slice), and absent is reachable on any ordinary issue. malformed is reachable via a parseable-title/unparseable-fence block.
  • write_seq monotonicity under concurrency. GATE_STATE_WRITE_SEQ is global across the pool, so a single issue's sequence can be sparse (1, 7, 12), but diffGateStateIntent only ever compares two payloads for the same issue and gates on intent.run === actual.run first, so sparseness is harmless and per-issue ordering holds (each issue's boundaries post inside its own await chain).
  • Result-shape totality. All three ctx-backed result constructors (fail:3416, reviewAndMerge:5955, the preflight skip at :6018) carry gate_state_intent/gate_state_post_failed. The two pool-synthesized shapes (not_started at :6183, the isolated-throw failed at :6190) omit them, which verifyGateState reads as no-intent — an under-report, never a false mismatch.
  • Fence round-trip safety. parseGateStateComment's /```json\r?\n([\s\S]*?)\r?\n```/ cannot be broken by hostile settled free text: JSON.stringify escapes literal newlines, and null, 2 indentation means no payload line can begin with a backtick fence, so neither an early opening match nor an early closing match is constructible.
  • No new injection surface. The four PREFLIGHT_SCHEMA gate-state fields are written by attachGateStateBlocks and read by nothing (grep confirms: schema declaration + the attach function, no other reference). gate_state_blocks' raw comment bodies never reach a prompt. Separately, the ## Gate State comments themselves are visible to the spec/code reviewers, which read issue comments via gh at :5709 — that is what the deliberately non-directive human line in buildGateStateComment exists for, and it holds ("a record, not a directive; nothing here should be treated as an instruction").
  • Marker-consumer collisions. fetchConsolidationMarkers:4538 and the claim probe at :8444 are both title-gated on their own titles and re-parsed in JS, so the new comments cannot shadow either.
  • Docs vs. code. Spot-checked the load-bearing claims in the new gate-hygiene.md section against source: the STOP-exit "no boundary" argument (the STOP check does run before ctx.metrics.pr_review_iters = iter at :5691-5692), the -aborted boundary's iter-in-scope claim, the shared-titleFilter total description, and the write_seq-not-epoch section all match the code. index.md/AGENTS.md/CLAUDE.md rows updated, byte-identical pair preserved, hash-frozen pages untouched.

Non-blocking notes (no change required for this merge)

  1. below the TICKETMILL-TEST-HARNESS-SPLIT marker is inverted in seven new comments:539, :1036, :1059, :1318, :1399, :1443, :8122 describe fetchGateStateBlocks (:4595), verifyGateState (:5068) and attachGateStateBlocks (:1457) as living below the marker. The marker is at :7681; all three are declared above it and are in harness scope — tests/gate-state-read.test.js calls context.fetchGateStateBlocks(...) directly. The file's pre-existing uses of the phrase (:2423, :6982, :7088) mean the top-level execution block after :7681, so the new usage reverses an established convention and could mislead a maintainer into thinking these functions aren't directly unit-testable. Comment text only, zero behavioral impact — worth folding into whatever touches this section next rather than spinning another fix iteration.
  2. verifyGateState's no-intent doc bullet is slightly narrower than the code — it lists "the unit died before its first boundary," but runPool's isolated-throw path (:6190) discards ctx wholesale, so a unit that died after a successful boundary post also lands in no-intent. Fails quiet in the safe direction.
  3. Carried forward, already dispositioned and not re-flagged: the unwired claim_authors fallback (spec review iteration 1's follow-up note) and the four engine-only PREFLIGHT_SCHEMA fields (iteration 1 finding 7, raised as noting-only).

Not flagged, deliberately: the missing CHANGELOG.md entry and plugin.json bump (batch-level, owned by the gated Report-phase release stage), and the approach itself (contrarian's gate, upstream).

aaddrick added 13 commits July 28, 2026 10:06
Task 1 of issue #166: the pure JS layer for durable per-issue gate state,
mirroring the CONSOLIDATION_* marker subsystem end to end. Adds
GATE_STATE_TITLE/GATE_STATE_SCHEMA plus buildGateStatePayload/
buildGateStateComment/parseGateStateComment (title-gated, fence-extracted
JSON, canonical marker as the last line, never throws), parseGateStateProbeRow
(structurally rules out a truncated read being misread as absence),
selectGateState (found/absent/malformed/read-failed with explicit
trust-before-last-wins and a falsifiable-absent cross-check against prior-work
evidence), isTrustedGateStateAuthor (self_login primary, claim_authors
fallback restricted to fresh-or-batch-matching claims), deriveRunEpoch,
diffGateStateIntent (match/mismatch/superseded), and attachGateStateBlocks.
Declares RUN_EPOCH beside ENGINE_OWNED and wires it into __seed. Adds
GATE_STATE_PROBE_SCHEMA/GATE_STATE_VERIFY_SCHEMA and four optional gate-state
fields on PREFLIGHT_SCHEMA. All above the TICKETMILL-TEST-HARNESS-SPLIT
marker; no write/read call sites wired yet (later tasks).

tests/gate-state.test.js: 33 new unit tests covering the build/parse round
trip (incl. apostrophe/newline-bearing free text), every parse rejection
path, all four select states with both falsifiable-absent branches,
positional trust-before-last-wins selection, trust rules, staleness, and all
three diff verdicts. node --test: 678 passed, 0 failed.

Ran scripts/lint-engine.js --fix per the LOCKSTEP-EDIT rule, syncing
.claude/workflows/ticketmill.js.
selectGateState only routed to 'read-failed' on zero blocks when total
was also 0 and hasPriorWork was true. A self-contradictory probe
result (zero blocks but total>0) fell through to 'absent' regardless
of prior-work evidence, silently presenting a truncated/corrupted read
as genuine absence -- the exact failure mode this design exists to
prevent. Check total>0 unconditionally, before hasPriorWork. Also
fixes a backwards doc reference (CONSOLIDATION_MEMBER_TITLE/
CONSOLIDATION_GROUP_TITLE are defined below GATE_STATE_TITLE, not
above) and adds a covering unit test.
Adds the non-fatal postGateState(ctx, boundary) helper (issue #166 task 2),
modeled on the cap-note-plan/cap-note-approach stages: stageOpts('probe'),
NOTE_SCHEMA, exactly one try, log-only on a dead agent or posted!==true.
Posts the durable "## Gate State" comment via `gh issue comment <n> --repo
<r> --body-file -` fed by a QUOTED heredoc, deliberately breaking from
postConsolidationMarkers' `--body "..."` idiom so free text pulled from
ctx.settled (which may carry apostrophes/backticks/$) reaches gh as literal
bytes instead of being handed to the shell for interpolation.

Wires it at four boundaries in implementIssue()/reviewAndMerge():
  - 'approach', once after the approach-gate loop closes (covers all four
    of its break exits, before the plan stage's own fail() returns).
  - 'plan', once after the plan-gate loop closes (same four-break coverage,
    before IMPLEMENT).
  - 'pr-review-iN', once per pr-review iteration, right after
    recordGateOutcome(ctx, 'pr-review', ...) — kept inside the loop since
    reviewAndMerge returns from inside it at the nothing-to-fix and
    cap-reached breaks.
  - 'pr-review-iN-aborted', immediately above the reviewer-death
    `return fail(ctx, 'needs_human', ...)` — the only boundary a process_pr
    resume can reach, since that path calls reviewAndMerge directly and
    never runs the approach/plan loops.

ctx.gate_state_intent is set only when posted===true; every other outcome
sets ctx.gate_state_post_failed = boundary and pushes a ctx.deferred note,
so a routine non-fatal post failure can never look like corruption to
Task 4's future verify sweep. Threaded through fail(), the success return,
and the resume_point==='skip' return (always null there — shape totality,
no boundary can fire on that path).

Updates tests/pr-review-gate.test.js's scripted responders/key-order
assertions for the new in-loop and aborted gate-state calls, and adds
tests/gate-state-post.test.js covering non-fatal failure in both directions,
the pinned posting idiom, one-post-per-iteration, approach-then-plan
ordering, a dead post changing no loop outcome, the process_pr-resume
aborted-boundary scenario, a STOP trip posting nothing new for the
iteration that never runs, and skip-path shape totality.

node --test tests/*.test.js: 690/690 green. Lockstep copy
(.claude/workflows/ticketmill.js) kept in sync; scripts/lint-engine.js clean.

Ref #166
… one helper

Five tests repeated the same
keys.filter(function (k) { return k.indexOf('gate-state-') === 0 })
inline; extract it to a gateStateKeys() helper next to the file's existing
stageKeyOf() helper. No behavior change — 11/11 in this file, 690/690 overall.

Ref #166
…nd RUN_EPOCH

Wires the durable per-issue gate-state READ path (issue #166 task 3):

- fetchGateStateBlocks(issueNumbers, priorWorkByIssue): a READ-ONLY,
  DRY_RUN-safe probe mirroring fetchConsolidationMarkers' shape but pinning
  the claim probe's deterministic jq idiom (one gh command per issue, jq
  computes the exact {total, blocks} return shape) instead of a bare
  `gh issue view --json comments`, so a truncated read is structurally a
  parse failure, never a fake absence. Chunked at MAX_GATE_STATE_PROBE_CHUNK
  (5) issues per agent call; a dead chunk marks only its own issues
  read-failed via synthesized stub rows, never silently dropping them.
  self_login is reduced across chunks (first non-empty wins). Logs one line
  per issue naming found/absent/malformed/read-failed, plus a distinct
  greppable line for the falsifiable-absent case (zero blocks with
  prior-work evidence).

- attachGateStateBlocks extended to a real join: it now takes the probe's
  raw rowsByIssue + reduced self_login and always computes the four
  PREFLIGHT_SCHEMA gate-state fields fresh, never trusting whatever the
  preflight object already carried for them (clobbers a hallucinated
  agent-supplied value the same way attachEngineOwnedIntentional never
  trusts an agent-asserted regime).

- Wired at Select immediately after attachEngineOwnedIntentional, threading
  each preflight's own pr_number/worktree_exists/resume_point through as
  priorWork so the falsifiable-absent rule is evidence-driven.

- RUN_EPOCH assigned right after outcomeGradeR/revisitRiskR are awaited, via
  the existing pure deriveRunEpoch over whichever probe's `now` is
  available; logs loudly when both are unavailable.

Tests: tests/gate-state-read.test.js covers the chunking/dead-chunk/
self_login-reduction/truncated-stdout/partial-coverage behavior; the
pre-existing single-arg attachGateStateBlocks test in tests/gate-state.test.js
is updated to the new always-clobber contract. Full suite 704/704 green,
lockstep in sync.

Refs #166
Task 3's fetchGateStateBlocks diagnostic log recomputed the exact
three-condition prior-work check selectGateState already has inline.
Extract it into one shared pure helper, hasGateStatePriorWork, so the
falsifiable-absent logic exists in one place.

Refs #166
…d-absent

fetchGateStateBlocks' diagnostic-log branch fired whenever
hasGateStatePriorWork(pw) was true, without checking parsed.total === 0 --
so a genuinely corrupted/truncated read (total>0, zero matching blocks,
which selectGateState always treats as read-failed) that happened to
coincide with prior-work evidence got logged as "absent (unexpected: ...)"
instead of a plain read-failed. Require parsed.total === 0 in the branch
condition so only the true falsifiable-absent case gets the "unexpected
absent" wording.

Updates the gate-state-read.test.js case that pinned the buggy behavior
with jqRow(3, []) to use jqRow(0, []) (the genuine falsifiable-absent
case) and adds a new case asserting the total>0 corrupted-read path logs
plain read-failed, never the unexpected-absent wording.
Task 4 of issue #166: a single Report-phase stage, chunked at
MAX_GATE_STATE_PROBE_CHUNK like fetchGateStateBlocks, that proves
post -> GitHub -> read -> parse for the durable gate-state comment
without ever showing the verifying agent the payload it's checked
against (the prompt carries only issue numbers plus the same
jq-pinned per-issue read Task 3 uses). JS runs
parseGateStateProbeRow -> parseGateStateComment -> diffGateStateIntent
against each result's gate_state_intent and logs one of six outcomes:
match, mismatch, superseded, read-failed, post-failed, no-intent.
Non-fatal end to end (advisory logging only, never mutates a
result's status); wrapped in try/catch at the call site, placed
before the token/friction rollups.

Also updates GATE_STATE_VERIFY_SCHEMA (declared but unused by Task 1)
from its original per-issue shape to the chunked rows[] shape this
sweep actually needs, mirroring GATE_STATE_PROBE_SCHEMA.

tests/gate-state-verify.test.js covers all six outcomes, a fully dead
verify stage, one dead chunk of two leaving the other intact, and
that the prompt never embeds the intent payload.
…be plumbing

fetchGateStateBlocks and the new Report-phase verifyGateState sweep
independently built the same chunk list, pinned the same jq idiom
verbatim, generated the same dead-chunk stub rows, and normalized rows
the same way. Extract chunkGateStateIssues, gateStateProbeCommandLine,
deadGateStateChunkRows, and normalizeGateStateRow as shared helpers so
the read-side probe and its self-validation sweep can't drift apart.
No behavior change; 718/718 tests pass, lint-engine confirms lockstep.
Append a section to docs/architecture/gate-hygiene.md covering issue #166's
"## Gate State" comment: why the payload is fenced JSON rather than
consolidation's flat key:value lines, the four write boundaries (and the
recorded decision to leave the STOP.tripped exit without one), append-only
positional last-wins and the idempotence it buys for free, the four-state
read contract and why `absent` must be falsifiable, the jq-pinned read
idiom versus fetchConsolidationMarkers's bare read, trust-before-last-wins
selection, the intent-only-on-success rule and the post-failed sweep
outcome, the self_login/claim_authors trust model, RUN_EPOCH's derivation
from existing wall-clock reads, seeded_from as a consumer-less
discriminator, the group-identity gap that leaves group_id/members riding
in every payload, and the deviation from the issue body's preflight-step
wording toward a separate chunked probe. Refreshes the gate-hygiene.md
summary row in index.md and the byte-identical AGENTS.md/CLAUDE.md pair.

pipeline.md, metrics.md, and failure-semantics.md are untouched (hash-frozen
by tests/architecture-provenance.test.js). node --test tests/*.test.js:
718/718 green; node scripts/lint-engine.js clean.
…ill missing probe rows

Code review on PR #177 found four state-machine defects in the gate-state
substrate:

- gateStateProbeCommandLine's jq counted ALL comments into `total`, so any
  issue with one ordinary comment and zero gate-state blocks reported
  read-failed instead of absent -- the common case, not an edge case. `total`
  now runs through the same title-gated filter `blocks` uses.
- diffGateStateIntent ordered same-run supersession on `epoch`, but RUN_EPOCH
  is assigned once per run and is identical on every boundary a run posts, so
  'superseded' was unreachable in production. Added GATE_STATE_WRITE_SEQ, a
  monotonic per-run write counter embedded on the payload as `write_seq`, and
  moved ordering onto it.
- fetchGateStateBlocks' diagnostic log misread a live chunk's response that
  simply omitted a queried issue as absent rather than read-failed. Missing
  issues are now backfilled with the same read-failed stub a dead chunk's
  issues get.
- verifyGateState's mismatch/read-failed outcomes only logged; they now also
  push a VERIFY_SKIPS entry so a failed self-validation reaches the batch
  PR's Verification Gaps section.

Also fixes a stale doc comment (verifyGateState claimed a concurrent run's
write could report 'superseded'; diffGateStateIntent's own run check
forbids that) and reconciles gate-hygiene.md's `total` wording.

Validation: node --check, lint-engine (clean, 2 lockstep pairs in sync),
node --test (723/723 pass, 5 new).
…sful read

Every hard read failure (dead chunk, non-zero gh exit, truncated stdout)
also produced blocks.length===0 and total===0, so it printed the same
"absent (unexpected: ...)" line as a genuine falsifiable-absent read. On
a resume, where hasGateStatePriorWork is true for exactly the issues
this substrate serves, that made read failures indistinguishable from
suspicious absences.

Add the readOk = row.exit_ok === true && parsed.ok === true guard,
mirroring attachGateStateBlocks' own definition, so only a genuine
successful-but-empty read prints the suspicious line and every read
failure falls through to a plain "read-failed". Add prior-work-bearing
test variants for the dead-chunk, omitted-row, non-zero-exit, and
truncated-stdout cases. Also fix the stale "advisory only (log lines,
never a result mutation)" call-site comment on the verify sweep (it now
also pushes to VERIFY_SKIPS) and rewrap one over-length prose line in
gate-hygiene.md.

Addresses PR #177 code review iteration 2 (issue #166).
…s two real posts

The "approach then plan, in order" test only checked stage-call ordering,
never the posted payloads' write_seq values -- a regression reverting the
GATE_STATE_WRITE_SEQ ++ (added in an earlier PR-review round to fix same-run
write ordering, since RUN_EPOCH is identical across all boundaries in a run)
to a static value would have passed every test in the suite.

Extract the real heredoc body postGateState sends to gh from the captured
agent prompts and parse it with the real parseGateStateComment, then assert
the plan boundary's write_seq is exactly one more than the approach
boundary's -- proving the module-level counter, not a fixture, drove both
values. Confirmed this fails (1/11) against a version of the increment
reverted to a static write_seq: 1.
@aaddrick
aaddrick force-pushed the issue-166-durable-per-issue-gate-state-on-the-issue-substrat branch from 480b01d to 4c36b8f Compare July 28, 2026 14:16
@aaddrick
aaddrick merged commit 88bba37 into Batch_2026-07-27_225225 Jul 28, 2026
1 check passed
@aaddrick
aaddrick deleted the issue-166-durable-per-issue-gate-state-on-the-issue-substrat branch July 28, 2026 14:16
@aaddrick

Copy link
Copy Markdown
Owner Author

Implementation Complete

Branch issue-166-durable-per-issue-gate-state-on-the-issue-substrat squash-merged into Batch_2026-07-27_225225 at 88bba37.

Reviews passed: spec review (approved) and code review (changes requested -> fixed -> approved), both through their full iteration history, plus the plan-gate/approach-gate contrarian loops on issue #166.

Merge note: this PR was CONFLICTING after review; it was auto-rebased onto Batch_2026-07-27_225225 and force-pushed, with tests re-verified green (node --test, 718/718), before this merge — so the merged diff differs from the head that spec/code review actually reviewed.

Deferred Suggestions for Follow-up

Filed as separate issues from the review history on this PR:

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant