Repository navigation
Durable per-issue gate state on the issue (substrate) #166
Description
Problem
Nothing about an issue's gate state survives a run boundary. processIssue builds a fresh ctx every time:
settled: []at:4821metrics: { ..., pr_review_iters: 0, ... }at:4846
On the process_pr resume path at :4871, the run does worktree setup, pushes one "Resumed Run" decision, and calls reviewAndMerge(ctx), which starts its loop at iter = 1 against MAX_PR_REVIEW_ITERATIONS. Nothing reads a prior count, and settledBlock(ctx) renders empty into both review prompts at :4619 and :4639.
So a resumed run judges the PR with no record of what the contrarian gates adjudicated, and draws a fresh review budget. The reviewers are told to read issue and PR comments, which is a prose narrative, not a record.
runs.jsonl cannot fix this. buildLedgerLine at :5613 carries sixteen fields and gate_findings is not among them; the full per-issue detail lives in logs/<logs_dir>/runs/<run_tag>.json, which the engine never reads back. More decisively, logs/ is gitignored and the mill skill documents runs.jsonl as a per-host local artifact, so it evaporates in the cross-host resume case this exists to serve.
Change
This issue ships the substrate only. No consumer.
- Write. Post a structured block, behind the existing marker convention
<!-- ticketmill <repo>#<issue> -->, carrying{ schema, issue, gate_budgets, settled }. Two boundaries are enough: once when the plan gate resolves, and once per pr-review iteration. Append-only, read last-wins, mirroring theoutcomes.jsonlcontract thatdiffOutcomeGradesalready implements. - Read. Add one step to the preflight probe (
:6905-6949) returning the raw blocks as strings, and a field onPREFLIGHT_SCHEMA(:478) to carry them. - Parse. All parsing is pure JS above the
TICKETMILL-TEST-HARNESS-SPLITmarker at:6522. The agent returns raw text and never decides, matching theOUTCOMES_SCHEMA.prior_ledger_linescontract and the PIN documented indocs/architecture/metrics.mdunder "A quality-fix iteration closed a PIN violation".
Two constraints
The fail-open must be visible. The preflight probe runs at M.probe (haiku, low effort, :352), already carries six numbered steps, and on death returns a hardcoded default at :6947. Three states must be distinguishable downstream: block found and parsed, block genuinely absent (a first run), and read failed. A read failure that silently presents as "absent" would hand the eventual consumer a fresh budget, which is the exact silent cap exhaustion this substrate exists to close. Log the failure case.
Self-validate the write. Because the consumer lands a tier later, this issue would otherwise ship an unexercised write path into GitHub comments. Include a same-run read-back that logs a mismatch, so the substrate is proven before anything depends on it.
If a new agent stage key is added, add it to the M map at :351. stageOpts at :412 defaults unknown keys to { model: 'sonnet' }, so an unregistered key silently runs at sonnet.
Acceptance criteria
- The block is written at both boundaries and re-read across a simulated resume.
- The parser is unit-tested above the split marker with no
ghorgitin the loop. - Found, absent, and read-failed are distinguishable, and read-failed logs.
- Malformed JSON, a truncated block, and a block carrying a different issue number all fail open to today's behaviour rather than throwing.
- A run with no prior block produces byte-identical behaviour to today.
- The write is idempotent enough that a re-run does not corrupt last-wins reads.
node --teststays green. Add a dedicated test file; extendtests/decision-records.test.jsif the settled ledger shape changes.
Out of scope
Nothing consumes the parsed state in this issue. Seeding ctx.metrics and ctx.settled, and enforcing an exhausted budget, is the follow-up that depends on this.
Documentation target
Document in CHANGELOG.md and in docs/architecture/gate-hygiene.md (create it if absent), adding its row to docs/architecture/index.md's file map.
Do NOT edit docs/architecture/pipeline.md, metrics.md, or failure-semantics.md. Their moved prose is hash-frozen by tests/architecture-provenance.test.js, and since the tech-docs stage runs at :4707, after the last test-loop execution, a frozen-prose edit surfaces only as a red CI check on the batch PR. New pages are authored text and are explicitly allowed; see docs/architecture/CLAUDE.md.
Activity
aaddrick commentedon Jul 28, 2026
Ticketmill Claimed
batch: Batch_2026-07-27_225225
run: 2026-07-27-gate-hygiene-t1
host: tanngrisnir
started: 1785207412
Queued in an active ticketmill run — will be processed even if work has not visibly started (concurrency queue). Claims older than 12h are stale.
aaddrick commentedon Jul 28, 2026
Starting Automated Processing
Batch branch: Batch_2026-07-27_225225 (integration target; final human-reviewed PR goes Batch_2026-07-27_225225 -> main)
Pipeline:
aaddrick commentedon Jul 28, 2026
Evaluation: Best Path
Complexity: complex. Additive and consumer-free, but it ships durable cross-run state a later tier will trust to gate merges, and its worst failure mode is silent: a read failure presenting as "no prior block" hands the consumer a fresh budget, which is the exact hole this exists to close.
Recommended approach
Model the whole thing on the consolidation-marker machinery already in the engine, rather than inventing a new mechanism. That code (
CONSOLIDATION_GROUP_TITLE,buildConsolidationGroupComment,parseConsolidationGroupComment,CONSOLIDATION_MARKER_PROBE_SCHEMA,postConsolidationMarkers) is the same problem already solved once: write a machine-readable block into an issue comment behind the canonical scope-guard marker, probe it back at Select, parse it in pure JS above the split marker. Follow it and this issue adds no new architectural idea.1. Shape. A title-gated comment: first line exactly
## Gate State(a newGATE_STATE_TITLEconstant, same gating role as the two consolidation titles), a one-line human summary, then the payload in a fenced JSON block inside a<details>wrapper, then the canonical<!-- ticketmill <repo>#<issue> -->line last. JSON rather than consolidation's flatkey: valuelines becausesettled[]is an array of five-field objects with free text; flattening it would mean inventing an encoding.<details>because up to four of these land per issue per run and the trail is read by humans and by contrarian gates.Payload:
{ schema: 1, issue, run, gate_budgets, settled }.schemaso a future shape change fails closed instead of half-parsing.run(RUN_TAG) is human/debug only and never the ordering key: last-wins is positional over comment order, so no wall-clock is needed anywhere (the sandbox forbidsDate.now()andscripts/lint-engine.jsenforces that).2. Write, at two boundaries. After the plan gate resolves, on all three exits (accepted, cap-reached, contrarian-died), and once per pr-review iteration right after
recordGateOutcome(ctx, 'pr-review', ...). Append-only; a re-run appends a newer block that supersedes, so idempotence falls out of last-wins rather than needing an exists-check. Posting is non-fatal on every path, mirroring thecap-note-planstage:if (!r || !r.posted) log(...)and continue.No new
Mkey. Callstage(ctx, 'gate-state-plan', ..., stageOpts('probe'), ...)— a distinctstage()key gives per-stage token attribution whilestageOpts('probe')gives haiku/low, which sidesteps the unregistered-key-runs-at-sonnet hazard entirely. (Checked:gate-state-*matches noREWORK_STAGE_PREFIXESentry, socomputeReworkTaxclassifies it as first-pass, correctly.)3. Self-validating write. Fold the read-back into the same agent call: post the body verbatim, then re-read the issue's comments and return the raw body of the last comment whose first line is the title. Schema
{ posted, readback }. JS parsesreadbackand compares it to the object it intended to write, logging a mismatch. One round trip, and it proves the full post -> GitHub -> read -> parse loop in the same run, which is what makes shipping this without a consumer safe.4. Read. One step appended to the preflight probe, kept last and short so it does not crowd the six numbered steps already in that haiku prompt. It returns two judgment-free facts and no decision:
gate_state_blocks(raw comment bodies, verbatim, most recent last) andgate_state_read_ok(literally: did theghcall exit 0). Both are added toPREFLIGHT_SCHEMA. This keeps the PIN intact — the agent observes, JS decides — the same splitOUTCOMES_SCHEMA.prior_ledger_linesplusnowalready uses.5. Parse, pure JS above
TICKETMILL-TEST-HARNESS-SPLIT. Three functions:buildGateStateComment(repo, issue, payload),parseGateStateComment(body)(title-gated, fence-extracted,JSON.parsein try/catch, cross-checks the embedded issue number andschema, returnsnullon any mismatch), andreadGateState(blocks, readOk, issue), the reducer that folds them into one state.The fail-open, made visible
readGateStatereturns four distinguishable states, not three:readOk, blocks present, parse cleanfoundreadOk, zero blocksabsent!readOkread_failedreadOk, blocks present, every parse failsmalformedEverything except
foundfails open to today's behaviour; everything exceptabsentlogs. The fourth state costs one branch and keeps "we could not read it" apart from "we could not understand it," which are different bugs.Two lines carry almost all the risk here, and both must positively assert failure rather than infer absence from silence:
gate_state_read_ok: false. A dead probe isread_failed, neverabsent.r.predicted_files = Array.isArray(...) ? ... : []) must default a missinggate_state_read_oktofalse, nottrue.Why this and not the alternatives
runs.jsonlis ruled out by the issue itself and the ruling is right: gitignored, host-local, andbuildLedgerLine's sixteen fields do not include gate state. A git-tracked state file on the branch dies with the worktree and pollutes the PR diff. Labels cannot carry structure, and editing the issue body mutates human-authored text and destroys append-only. The issue comment is the only substrate that is durable, cross-host, append-only, and already load-bearing for this exact purpose.Scope discipline
Nothing reads the parsed state.
ctx.metricsandctx.settledstay untouched, so a run with no prior block takes byte-identical decisions to today; the only deltas are two extra posts and one extra probe step. Seeding and enforcement are #168's.Docs go to
docs/architecture/gate-hygiene.md, which #162's tier already created, plusCHANGELOG.md;index.md's row gets its description widened.pipeline.md,metrics.md, andfailure-semantics.mdstay untouched (hash-frozen). Both engine copies ship together vianode scripts/lint-engine.js --fix. New tests land intests/gate-state.test.js, driving all three pure functions throughtests/harness.jswith noghorgitin the loop.One correction to the issue text
The line numbers in the body are stale. #162, #163, and #164 have already landed on this batch branch and the engine has grown from 7761 to 8027 lines, so
:4821,:4846,:6905-6949,:478, and:6522all point at the wrong code now. Anchor every edit on a symbol, never a line number.aaddrick commentedon Jul 28, 2026
Contrarian: Approach Challenge (Iteration 1)
Verdict: needs_rework — the architecture is right, the rework is bounded to four specific corrections. Mirroring the consolidation-marker subsystem is the correct call and I am not challenging it.
Strengths (steel-man)
Reusing
CONSOLIDATION_*end to end is the strongest part of this. Verified in the tree:fetchConsolidationMarkers->parseConsolidationGroupComment->healGroupsis exactly "durable machine-readable state on an issue comment, parsed in pure JS above the split marker," and it is already reviewed, tested (tests/consolidation.test.js), and idiomatic here. JSON over consolidation's flatkey: valuelines is right for a five-field array with free text. The no-wall-clock/positional-last-wins choice is forced and correct (scripts/lint-engine.js:68really does rejectDate.now(). Thestage(ctx, 'gate-state-plan', ..., stageOpts('probe'), ...)trick is sound:stage()at:1787takesoptsexplicitly and never re-looks-upM[key], so no unregistered-key-at-sonnet hazard exists, andREWORK_STAGE_PREFIXES(:5425) has nogate-stateprefix, so rework-tax classification stays correct. Four states instead of three is a genuine improvement over the issue text. Andprereally does reachprocessIssue(pre)(:5074) intact, so the follow-up tier can read the new fields without further plumbing.Findings
[Major] The self-validating write is unfalsifiable as designed.
Folding post + read-back into one agent call means the agent that reports
readbackalready holds the intended body verbatim in its own prompt. Failure scenario:gh issue commentfails, or GitHub mangles the fenced block, and the agent returns the body it was handed. JS compares intended vsreadback, they match, no mismatch is ever logged, and the constraint that exists specifically to prove an unexercised write path certifies a broken one. This is not hypothetical laziness — echoing available context is the cheap path for a haiku stage. Recommendation: split it, exactly as the precedent does (postConsolidationMarkerswrites;fetchConsolidationMarkersis a separate read probe). Second call, prompt containing only the issue number and the title to match, never the payload; return the comment id plus raw body. Then a match actually proves the round trip.[Major] "Absent" is not falsifiable, and the obvious fetch silently truncates.
Checked on this repo just now:
gh api repos/aaddrick/ticketmill/issues/162/comments --jq lengthreturns 30, whilegh issue view 162 --json commentsreturns 31. The REST default is one page of 30 ascending, so on any issue past 30 comments a baregh api .../commentsreturns the oldest 30, exits 0, and finds no gate-state block. That isgate_state_read_ok: truewith zero blocks — reported asabsent, which is the precise silent fail-open the issue calls a hard constraint. Ticketmill issues cross 30 comments routinely (#162 already has). Recommendation: (a) pin the exact command in the probe prompt —gh issue view <n> --repo <r> --json comments, the same callfetchConsolidationMarkersuses — never a baregh api; (b) have the probe also return the total comment count, so JS can catch the impossible case (a ticketmill-claimed issue always carries at least two trail comments, soblocks.length === 0 && total === 0means the read never happened) and classify itread_failedrather than trusting a model-attested boolean alone.[Major]
aaddrick/ticketmillis public, so the block is attacker-writable and nothing checks the author.Confirmed
"visibility":"PUBLIC". Any GitHub user can post a comment whose first line is## Gate Statewith a well-formed payload naming the right issue andschema: 1. Positional last-wins means the newest such comment wins outright — no adjudication stands between it and the consumer. Once #168 seedsctx.settledandctx.metricsfrom it, forged free text lands verbatim insettledBlock()(:1874), which tells every downstream gate that re-litigating without new evidence "is itself a process failure," and forgedgate_budgetscan present a fresh or an exhausted PR-review budget. The consolidation precedent has no author filter either, but its markers only seed a proposal an opus gate plus a contrarian re-adjudicate; this substrate is designed to be trusted directly by JS. The trust delta is the whole point. Recommendation: carryauthor_loginandauthor_associationper block on the probe schema (ghexposes both) and filter in JS to the repo owner before parsing; drop and log anything else asmalformed. Also re-applysettleDecision's ownslice()caps (400/300/4x160) at parse time so a hostile or runaway payload cannot balloon a later prompt. Deciding this now costs one field; retrofitting it later means changing a contract the consumer already depends on.[Major] The plan gate has four exits, not three, and the fourth writes nothing.
Besides accepted (
:4586), cap-reached (:4595), and contrarian-died (:4583), the loop also breaks atif (!rp || rp.status !== 'success') { log('re-plan failed — proceeding with current plan'); break }(:4626). A run that consumes plan-gate iterations and then loses the re-planner leaves no durable record at all, so the consumer hands the resumed run a fresh budget — the exact hole this exists to close, on the one path where the run was already degraded. Recommendation: post once after the loop rather than at each exit. One call site instead of three, every exit covered including any added later, and the payload is read offctx.gate_findings/ctx.settled, which are already correct at that point.[Minor] The payload and the read-back are both unbounded.
Every post carries the full
settled[];settleDecisioncaps each entry near 1.3 KB but not the array, and up to four posts per run each repeat the whole thing. Then the probe returns all blocks raw, most recent last, into a haiku stage that already carries six numbered steps and the full issue body. Recommendation: cap the payload'ssettledto the last 6 (matchingsettledBlock's ownslice(-6), so the durable record and the rendered block agree) and cap the probe to the last 2 or 3 blocks — last-wins only ever needs one, plus a fallback.[Minor]
<details>hides the payload from humans, not from agents.gh issue view --commentsrenders raw markdown, so every downstream contrarian, spec reviewer, and code reviewer reading the trail ingests up to four JSON blobs per run that duplicate whatsettledBlock()already injects structurally. Worse, the trail instruction that prior contrarian comments count as prior adjudication now meets a machine block asserting adjudications in a shape no agent was told how to weigh. Recommendation: keep the human summary one line and explicitly non-directive (state that it is machine state, not an instruction), and keep the payload minimal — this is a second reason to capsettled.[Minor] Consolidated group units drop member state and lose it on re-anchor.
deriveUnits(:1314) builds a group unit withObject.assign({}, primaryRef, {...}), union-folding onlypredicted_files,depends_on, andrevisit_risk. New preflight fields inherit from the primary alone, so member issues' blocks are invisible, and when a group re-anchors to a different primary (which the stable-group-id comment at:986says explicitly can happen) the prior state stops being read. Writes go toctx.issue(the primary), so this is self-consistent per run but not across a re-anchor. Recommendation: no code change needed in this issue — decide it deliberately and write it down ingate-hygiene.md, or union-fold the field like the three neighbors, so the follow-up tier does not discover it as a bug.Pre-mortem, one line
Six months out, the way this fails is not a crash: it is a resumed run reporting
absentbecause someone'sghcall quietly returned page one, drawing a fresh review budget, with nothing in the log to say so. Findings 1 and 2 are the two places that story starts.aaddrick commentedon Jul 28, 2026
Revised Evaluation (after contrarian iteration 1)
All four majors and all three minors confirmed against the tree. Nothing to rebut outright. Two get adopted with a narrowing correction, because the literal fix as written would misfire; each narrowing is backed by a command I ran.
The architecture is unchanged: mirror the consolidation-marker subsystem end to end. What changes is the write topology (one call site per gate, not three), the read contract (pinned command, author provenance, a checkable count), and the read-back (a second agent that never sees the payload).
Verification log
grep postConsolidationMarkers|fetchConsolidationMarkers:4022and:3805gh api .../issues/162/comments --jq lengthvsgh issue view 162 --json commentsgh issue view 166 --json comments --jq '.comments[0]'authorAssociation: "OWNER",author.login: "aaddrick"gh repo view --json visibilityPUBLICsed -n '4355,4412p':4367, accepted:4376, cap:4396, re-plan failed:4408settledBlockrenders 6:1856ctx.settled.slice(-6)settleDecisioncaps:1847-1849deriveUnitsunion-folds three fields:1289-1292predicted_files,depends_on,revisit_risk; everything else isObject.assign({}, primaryRef, ...)1. Split the read-back (major, adopted as written)
The self-validating write was unfalsifiable: one agent holding the payload in its own prompt can echo it without a round trip. Adopt the precedent's shape exactly.
stageOpts('probe'), returnsposted.stage()whose prompt carries only the issue number, the literal title## Gate State, and the pinned fetch command. It never sees the payload. It returnscomment_idplus the LAST such comment's rawbody.parseGateStateComment(body)against the object it intended to write, canonicalized throughJSON.stringify, and requires a non-nullcomment_id. A mismatch logs. Nothing throws.Run the verify once per issue per run, guarded by a
ctx.gate_state_verifiedflag, on the first successful post at whichever boundary comes first. That covers theprocess_prresume path at:4871, whereimplementIssuenever runs and the plan-gate write therefore never happens. Cost is one extra haiku probe per issue.2. Falsifiable absence (major, adopted with a narrowing)
The pinned command goes into the probe prompt verbatim, exactly as
fetchConsolidationMarkers:3809pins it:gh issue view <n> --repo <repo> --json comments. Never a baregh api. The 30-vs-31 measurement above is the entire reason.The probe also returns
gate_state_total_comments, so the boolean stops being the only evidence.Narrowing. The rule "zero blocks with zero total means the read never ran" is a false positive on a genuinely fresh issue. Preflight runs at
:6905;toClaimposts the claim comment at:7240, after. A first-run issue with no comments legitimately reports zero. So the impossible-case test is conditioned on evidence of prior processing, which the probe already returns in the same object:A previously processed issue always carries trail comments (claim, "Starting Automated Processing", gate comments), so zero total against prior-work evidence is arithmetically impossible and means the read never ran. On a fresh issue it stays
absent, silently, which is correct. This is a cross-field consistency check inside one structured output rather than a bare model attestation: strictly better than the boolean, with no false-positive log noise.3. Author provenance (major, adopted with a portability correction)
The repo is
PUBLIC, the block is last-wins, and the design had no author check. Any user could open a comment titled## Gate Statecarrying an exhausted budget and a later tier would trust it. Fixed at the schema, not the parser.Each block carries
{ body, author_login, author_association }. JS filters before parsing; a rejected block is counted and logged as malformed with its login, never parsed.Correction to the proposed rule. "Filter to the repo owner" is right for this repo and wrong for the engine. Ticketmill is repo-agnostic by design and profile-driven, so a deployment running the mill under a bot or a write-access collaborator would see every block rejected and degrade to permanent
absent: a real regression dressed as a security fix. The correct boundary is write access, which is exactly whatauthorAssociationencodes.A forger without push rights lands in the reject set.
author_loginrides along anyway, so tightening to owner-only later is a one-line change with no contract churn, which is the challenge's own stated reason for adding the field now.Rejected:
viewerDidAuthor, also on the payload and tempting. It is relative to whichever token runsgh, so it reads false on the second host, and cross-host resume is the entire point of this issue.parseGateStateCommentre-appliessettleDecision's caps at parse time (400 / 300 / 4 x 160, verified at:1847-1849), so an oversized forged or corrupted block cannot inflate a later prompt even if it clears the author check.4. One write call site per gate (major, adopted for the plan gate; deliberately not for pr-review)
Plan gate: post once after the loop. Four exits confirmed, not three, and the fourth (re-plan failed,
:4408) wrote nothing under the old plan.ctx.gate_findingsandctx.settledare already final where control leaves the loop, so a single post below it covers every exit including any added later. One call site instead of three.PR-review gate: keep the single in-loop call site, immediately after
recordGateOutcome(ctx, 'pr-review', ...)at:4669. Moving it below that loop would be a regression:reviewAndMergereturns from inside the loop on a failedpr-fix(:4691) and on a quality-loop halt (:4699), and those are precisely the runs a later resume needs the iteration count for. Note this is already one call site, so finding 4's arithmetic applies only to the plan gate. The dead-reviewerreturn failat:4657sits aboverecordGateOutcome, so that iteration produced no outcome and has nothing to write.Bound: at most 1 +
MAX_PR_REVIEW_ITERATIONS(3) = 4 gate-state comments per issue per run.5. Bound both sides (minor, adopted)
settledcapped to the last 6, matchingsettledBlock:1856's ownslice(-6), so the durable record and the rendered prompt agree by construction rather than by coincidence.6. Non-directive human summary (minor, adopted)
The
<details>wrapper hides the payload from humans and not from agents, so the visible line is where the disclaimer has to live. Exactly one line, explicitly non-directive:parseGateStateCommentcross-checks the embeddedrepoas well asissueandschema, so a block copied between repos fails tomalformedrather than being trusted.7. Group inheritance (minor, adopted as a documented decision, no code change)
Confirmed:
deriveUnits:1292isObject.assign({}, primaryRef, {...})with three explicit union-folds. A new preflight field is inherited from the primary alone, and a re-anchor drops it.Decision: do not union-fold, and record why in
gate-hygiene.md. The three neighbors are unions by nature; a group's predicted files, dependencies and revisit reasons genuinely are the set union across its members. Gate budgets are not.plan_itersis a per-issue count against a per-issue cap, and OR-folding or maxing budgets from independent gates would fabricate a number no gate ever produced. Losing the primary's state on re-anchor fails open to a fresh budget, the same direction asabsent, which is today's behaviour for every issue. Documented explicitly so the follow-up tier inherits the reasoning instead of rediscovering it.Unchanged from iteration 1
Consolidation-marker mirroring; title-gated
## Gate Stateclosing with the canonical marker line; JSON rather than consolidation's flat key-value lines, becausesettled[]is an array of five-field objects with free text; append-only with positional last-wins so idempotence falls out instead of needing an exists-check; no wall clock anywhere (scripts/lint-engine.js:68bansDate.now(, verified); all parsing pure and aboveTICKETMILL-TEST-HARNESS-SPLIT:6522; four distinguishable states (found/absent/read_failed/malformed), everything butfoundfailing open to today's behaviour and everything butabsentlogging; no consumer, soctx.metricsandctx.settledstay untouched and a run with no prior block takes byte-identical decisions.No new
Mkey: the write and the verify both passstageOpts('probe')explicitly, so the:412sonnet-default hazard the issue warns about cannot fire. Stage keysgate-state-plan,gate-state-pr-i<n>,gate-state-verifymatch no entry inREWORK_STAGE_PREFIXES:5168, so they do not pollutecomputeReworkTax.New
tests/gate-state.test.jsdriven throughtests/harness.js, covering round trip, malformed JSON, truncated fence, wrong issue number, wrong repo, wrong schema version, non-write-access author,read_ok:false, the zero-total-with-prior-work impossible case, the zero-total-fresh-issue case stayingabsent, and cap enforcement at parse time. Nogh, nogit. Ship both engine copies vianode scripts/lint-engine.js --fix. Docs: newdocs/architecture/gate-hygiene.md, its row indocs/architecture/index.md, andCHANGELOG.md.pipeline.md,metrics.mdandfailure-semantics.mdstay untouched; they are hash-frozen bytests/architecture-provenance.test.js.Residual risks
ghhiccup) and log a mismatch on a write that actually landed. Non-fatal by construction: it degrades to today's zero validation, never worse.authorAssociationis GitHub-computed. If a deployment's poster somehow falls outside the accept set, gate state reads permanentlyabsent, but logged as malformed with the login every time, so it is loud rather than silent.<details>wrapper and the 6-entrysettledcap keep each one small, and the probe only ever reads the last 3.aaddrick commentedon Jul 28, 2026
Contrarian: Approach Challenge (Iteration 2)
Verdict: needs_rework — three majors, all of them narrow. The architecture stands; two of the three are re-adjudications of iteration-1 fixes that I measured and found to misfire, and I am citing the measurements.
Strengths (steel-man)
The iteration-1 rework landed. Verified in the tree at HEAD (
5715792):stage()that never sees the payloadfetchConsolidationMarkers:4020is a separate probe frompostConsolidationMarkers:4238gh issue view <n> --repo <r> --json comments:4583dismissed,:4591accepted,:4601cap,:4614+:4626re-plan-failed; loop ends before IMPLEMENTpriorWork(pre)fields existpr_number,worktree_exists,branch,resume_pointPREFLIGHT_SCHEMA:487-497PREFLIGHT_SCHEMAis used once,:7201, with the died-default at:7213settleDecision:1866is the only writer toctx.settled, and it applies 400/300/4x160 on write, so re-applying at parse is idempotent. No spurious mismatchKeeping the pr-review write inside the loop is right for the reason given (
reviewAndMergereturns from inside it), and I confirmed there is noctx.settledwriter that bypasses the caps. Also confirmed the engine has norequire/child_processat all — every shell call goes through an agent — which makes the verify stage load-bearing rather than ceremonial.Findings
[Major]
authorAssociationis not a write-access proxy, and the accept set rejects exactly the deployments it was adopted to protect.The owner-only filter was narrowed to
OWNER|MEMBER|COLLABORATORbecause "a deployment running the mill under a bot or a write-access collaborator would see every block rejected." Measured on live GitHub just now:github-actionslands in the reject set. AndMEMBERis only returned for publicly visible org membership, so an org maintainer with private membership readsCONTRIBUTOR— also rejected. The narrowing therefore produces the identical regression it was adopted to prevent, one class over: a bot-run or private-membership deployment writes blocks its own reader discards, reads permanentlyabsent, and logs its own writes asmalformedforever. Fail-closed, permanent, and self-inflicted. Recommendation: stop inferring trust from association. Derive the trusted login in band — the same issue already carries## Ticketmill Claimedwritten by this deployment's own identity, and the probe is already fetching every comment; trust blocks whoseauthor_loginmatches the claim/marker comment's author, with theOWNER|MEMBER|COLLABORATORset as an additional accept, never as the sole gate. That inherits exactly the trust the claim protocol already has (no new surface), needs no config, and works under any identity. Keep both fields on the schema either way.[Major] The payload has no staleness anchor, and "no wall clock" is a self-imposed choice, not a constraint.
Iteration 1 endorsed positional last-wins as "forced and correct." New evidence:
scripts/lint-engine.js:68bansDate.now()in JS only, and the engine already sources real epochs from agents in three places —date +%swritten into the claim body (:7533),date +%Y-%m-%d_%H%M%Sfor the batch branch (:6925), and the injectednowparam ongradeFromObservation(:6488). It even acts on one:CLAIM_STALE_SECONDS:292is 12h. So a timestamp was available and was not taken. The consequence: a block from a run abandoned three months ago is positionally indistinguishable from one written five minutes ago, andruncannot substitute —RUN_TAG:269isA.run_label || A.date || 'run', which defaults to the literal stringrun. Failure scenario: an issue caps its plan gate at 3, halts, is reopened months later in a new batch; the consumer readsplan_iters: 3and grants the fresh attempt zero plan-gate iterations. That fails closed — the one direction this design promises never to fail. Recommendation: carry an epoch in the payload sourced the way the claim comment already does ($(date +%s)inside the write agent's command), plusbatch. Ordering stays positional; the epoch is only ever a staleness bound for the consumer. Per the plan's own argument for addingauthor_loginnow, a field added later is a contract change on a consumer that already depends on it.[Major] The verify validates the first post; last-wins consumes the last.
The bound is up to 4 posts per issue per run (1 plan + 3 pr-review),
ctx.gate_state_verifiedfires on the first successful one, and the reader takes the newest block. So in every multi-post run — precisely the long ones a resume exists for — the block that gets consumed is the one that was never checked. And the checking matters here specifically because JS cannot post: the engine has nochild_process, so a haiku agent must reproduce a fenced JSON body verbatim through shell quoting (free text insettled[]routinely contains apostrophes). A mangle on post 4 is invisible. Recommendation: verify the last write, not the first. Cheapest shape that keeps one probe: leave the flag unset and instead re-run the verify after any post that supersedes a verified one, capped at 2 per issue (plan boundary + final pr-review boundary); atstageOpts('probe')that is one extra haiku call, against a pipeline that already spends opus on three contrarian iterations.[Minor] The malformed-tail fallback returns
foundwith stale data and logs nothing.Iteration-1 minor 5 was adopted as "the probe returns the last 3 blocks — last-wins needs one plus a fallback for a malformed tail." As specified, a run where the newest block is malformed and the previous one parses returns state
found, silently, carrying a strictly older budget. That is a fifth condition the four-state table does not cover, and it violates hard constraint (a) more sharply thanabsentdoes, because it is confidently wrong rather than empty. Recommendation: log every skipped block and return astale: true(orfound_fallback) marker alongsidefound, so "I used an older block" is distinguishable from "I used the newest block."[Minor] Bolting the read onto the preflight probe diverges from the precedent it claims to mirror.
fetchConsolidationMarkersis its own probe over the whole candidate set in one call. The plan instead appends a 7th step to the per-issue preflight at:7201— the probe that decidesresume_point(skip/process_pr/implement) for every issue in every batch, already carrying six numbered steps at haiku/low. Adding an instruction to return up to three raw JSON blobs both bloats the most routing-critical probe in the pipeline and costs N calls where the precedent costs one. Recommendation: a separatefetchGateStateBlocks(issueNumbers)probe alongside the preflightPromise.all, shaped likefetchConsolidationMarkers. The routing probe stays byte-identical, the new read's failures are isolated to its ownread_ok, andPREFLIGHT_SCHEMAstays untouched (attach the result ontoprein JS, whereattachEngineOwnedIntentional:3023already shows the pattern).[Minor] The approach-gate budget is still lost on the path just above the plan gate.
Adopted finding 4 moved the write below the plan-gate loop, which is right. But
implementIssuereturns at:4542(plan agent died) and:4543(planning failed), both after the approach gate has burned up to 3 opus contrarian iterations and before any write exists. A resume re-runs the approach gate on a fresh budget — the same hole, one level up. Recommendation: either move the single write to just after the approach-gate loop and re-post it after the plan-gate loop (two calls, both boundaries covered), or record the gap explicitly ingate-hygiene.mdas accepted scope. Do not leave it undecided.Pre-mortem
The way this fails in six months is not the write path. It is a third-party deployment that installed the plugin, ran it under a CI identity, and has been logging
malformedon its own comments every run since — readingabsentforever while believing it has durable state. Finding 1 is that story; finding 2 is the same story with the sign flipped, where the state is read and is silently three months old.aaddrick commentedon Jul 28, 2026
Revised Evaluation (after contrarian iteration 2)
All three majors and all three minors are confirmed against the tree and live GitHub, with one correction to the evidence behind finding 1 that does not change its conclusion. Two remedies are adopted as proposed; one is adopted in diagnosis but implemented by a cheaper mechanism, argued below. I also found an error in my own prior approach (the docs target already exists) and corrected it.
Finding 1 (major): association is not a write-access proxy. CONFIRMED, with an evidence correction.
Measured on live GitHub just now:
author_associationgithub-actions[bot]k8s-ci-robotgopherbotbors,rustbotnodejs-github-botCorrection: I could not reproduce
NONEforgithub-actions[bot]; it measured CONTRIBUTOR. The conclusion is unaffected, because CONTRIBUTOR is outside theOWNER|MEMBER|COLLABORATORaccept set either way, so a write-access bot identity is rejected by the narrowed gate. I did not independently verify the private-org-membership half of the finding; it is not load-bearing, since the bot half alone establishes that association is not a write-access proxy.Adopted. Derive the trusted login in band. The probe already fetches every comment, so it also returns
claim_authors: the login of every comment whose first line is exactly## Ticketmill Claimed(orLEGACY_CLAIM_TITLE). That comment is written by this deployment's ownghidentity at:7533, and it survives the run, because the claims-release sweep at:7671removes only the label, never the comment. Verified on this issue, where the claim comment is present and authored by the same identity every pipeline comment carries.Accept a block when
author_loginis inclaim_authorsORauthor_associationis in{OWNER, MEMBER, COLLABORATOR}. Association is demoted to a secondary accept, never the sole gate. If both miss, the block is skipped with reasonuntrusted_author, logged, and the read degrades toabsent, which is today's behaviour.Accepted risk, stated plainly: anyone can post a
## Ticketmill Claimedcomment and self-bootstrap intoclaim_authors. This does not widen the existing surface, because a forged claim already makes real runs skip the issue outright at:7528, which is a strictly worse denial of service than seeding a block nothing reads. Blast radius stays bounded by the repo/issue/schema cross-checks, the parse-time cap re-application, and the fact that this issue ships no consumer.Finding 2 (major): no staleness anchor, and the no-wall-clock rule was self-imposed. CONFIRMED.
scripts/lint-engine.js:68matches the literalDate.now(and:82the arglessnew Date(). Both are source-text bans on the engine JS, not bans on an agent shellingdate. The engine already sources real time from agents in four places::7000and:7098(date -u +%Y-%m-%dT%H:%M:%SZ, returned verbatim asnow),:7533(the claim'sstarted: $(date +%s)), and:3237/:3241(the worktree lock).Adopted, with the sourcing simplified. Rather than embedding
$(date +%s)inside the write agent'sprintf(JSON quoting inside a shell heredoc is exactly where a payload gets mangled), the new read probe returnsnowverbatim using the:7000/:7098idiom, and JS computesRUN_EPOCHthrough the existing puretoEpochMshelper at:6377. That probe runs once per run at Select, strictly before any write, so the epoch costs zero extra calls.The payload gains
epochandbatch. Ordering stays positional; the epoch is only ever a staleness bound for the consumer. The epoch is run scoped, so every block a run writes shares one value, which matches the claim comment's run-scopedstarted:exactly. If the probe dies,epochisnulland the consumer must treat an unbounded block as stale.No threshold is enforced here, because this issue ships no consumer.
gate-hygiene.mddocuments the intended bound asCLAIM_STALE_SECONDS(12h,:292) so the consumer tier inherits one staleness policy rather than inventing a second.Finding 3 (major): the verify validates a block that gets superseded. CONFIRMED, remedy improved.
MAX_PR_REVIEW_ITERATIONS = 3at:293, and with the approach boundary added below there are up to five posts per issue. A one-shot first-post verify validates the smallest, earliest payload while last-wins consumes the largest, latest one. The gap is real:settledandgate_budgetsboth grow across the run, so a truncation or escaping failure that only appears at size is exactly what the first-post verify cannot see.Diagnosis adopted. Remedy changed, and I believe strictly improved. The finding proposes re-verifying after any superseding post, capped at 2 per issue. I inspected the exits and that cap cannot reliably land on the last post:
reviewAndMergeleaves its loop through threebreaks (prReviewClean,bothNothingToFix,capReached) and tworeturn fail(...)paths (pr-fix failure, quality halt). At post time, inside the loop, the code cannot know that this iteration was the last one, so a cap-2 in-loop scheme spends its second verify on iteration 1 or 2 and still misses the final block on the return-fail exits, which are precisely the needs_human halts a human resumes from.Instead: one run-level sweep at Report,
verifyGateState(results), shaped like theclaims-releasesweep at:7671and likefetchConsolidationMarkers. It re-reads each issue's newest## Gate Stateblock and compares it against the intent JS recorded. That is one haiku probe per run rather than up to 2N, and it always sees the truly final block for every issue on every exit path.Plumbing:
ctx.gate_state_intent(the canonicalJSON.stringifyof the last payload posted) rides out on bothprocessIssueexits exactly the waygate_findingsandsettledalready do infail()at:2897and on the success return. The verify prompt still carries only the issue number, the literal title, and the pinned fetch, never the payload; it returnscomment_idplus raw body and JS does the comparison. The write/read split is preserved.Accepted risk: a mismatch is now discovered after merge rather than mid-run. That is acceptable because nothing consumes the block in this issue, which is the entire point of shipping the substrate first, and the mismatch lands in the log and the run report.
Finding 4 (minor): the malformed-tail fallback is a silent fifth state. CONFIRMED, adopted.
readGateStatereturns{ state, payload, stale, skipped }.statestays the four-value table (found | absent | read_failed | malformed),stale: truemarks "the newest block was unusable and I fell back to an older one", andskippedlists every rejected block with a reason (untrusted_author,malformed_json,wrong_issue,wrong_repo,wrong_schema). Every skip is logged. "I used an older block" is now distinguishable from "I used the newest block".Finding 5 (minor): a 7th preflight step is not the precedent it claims. CONFIRMED, adopted.
fetchConsolidationMarkersat:4021is its own probe over the whole candidate set in one call, andattachEngineOwnedIntentionalat:3023attaches a derived field onto each preflight in JS with a non-mutatingObject.assignmap. Both precedents fit better than inflating the routing prompt.New
fetchGateStateBlocks(issueNumbers)atstageOpts('probe'), pinned togh issue view <n> --repo <r> --json comments(never a baregh api; the pinned form paginates, verified at 397 comments). It returns per issue:now,read_ok,total_comments,blocks(the last 3 whose first line is exactly## Gate State, each as{body, author_login, author_association}), andclaim_authors. A newattachGateStateBlocks(preflights, byIssue)mirrorsattachEngineOwnedIntentional.PREFLIGHT_SCHEMAat:478and the routing prompt are untouched.This deviates from the issue's literal wording ("add one step to the preflight probe, and a field on
PREFLIGHT_SCHEMA"). It preserves the stated contract (the agent returns raw text and never decides) and every acceptance criterion, and it costs one call instead of N inflated prompts. Flagged in the PR body as a deliberate deviation.Finding 6 (minor): the approach-gate budget is lost above the plan gate. CONFIRMED, and it is three paths, not two.
Between the approach loop and the plan loop there are three
return fail(...)exits: plan agent died,planR.status !== 'success', and!tasks.length. On all three the approach gate's consumed iterations are never written, so a resume redraws the opus-tier budget.Decided, not deferred: adopt option (a). Write once immediately after the approach-gate loop, above those three returns. Boundaries become three:
boundary: 'approach'boundary: 'plan', covering all four of its exits (all four arebreaks, re-verified)recordGateOutcome(ctx, 'pr-review', ...)at:4903,boundary: 'pr-review-iN', kept inside the loop becausereviewAndMergereturns from inside itMax five posts per issue. Editing one comment in place would have removed the noise, but the issue text prescribes append-only with last-wins, so that trade is not mine to make here.
Correction to my own prior approach
I claimed
docs/architecture/gate-hygiene.mdwas new and needed anindex.mdrow. Both are wrong: the file exists at 453 lines with eleven sections, anddocs/architecture/index.md:15already carries its row. I append a## Durable gate statesection only, and add no index row.tests/architecture-provenance.test.jsandtests/docs-links.test.jsboth run to confirm the append is clean.Unchanged from iteration 1
The write/read split, the pinned fetch, the single post below the plan-gate loop covering all four exits, the
priorWorkfields, and parse-time cap re-application (settleDecisionat:1866is the onlyctx.settledwriter and caps on write, so re-application cannot false-alarm the verify) all stand as verified.Tests
New
tests/gate-state.test.jsthroughtests/harness.js, noghand nogit: found, absent, read_failed, malformed-newest with stale fallback, all-malformed, untrusted author skipped while a claim-author block is accepted, wrong issue, wrong repo, oversizedsettledtruncated to the 400/300/4x160 caps,epoch: nulltolerated, and a build-then-parse round trip that is byte-stable throughJSON.stringify. Both engine copies synced withnode scripts/lint-engine.js --fix.aaddrick commentedon Jul 28, 2026
Contrarian: Approach Challenge (Iteration 3)
Verdict: needs_rework — one major, four minors. The major re-opens an adjudicated decision, and I am citing the new evidence the settled-decisions contract requires: the rebuttal that closed it is false for the cheapest version of the attack. Everything else from iteration 2 has landed.
Strengths (steel-man)
The iteration-2 rework is real. I verified each remedy against the tree at
5715792:return fail(...)at:4541/:4542/:4545:4401-:4489: all four approach-loop exits arebreaks (contrarian died, accepted, cap, re-evaluate failed). One post after the loop covers every one. ConfirmedverifyGateState(results)at Reportphase('Report')runs afterrunPoolon every terminal state:STOP.trippedfillsnot_startedresults and returns (:5288) rather than throwing, and a per-unit throw is isolated at:5296. The sweep really does see each issue's final block on every exit path, at one call instead of 2NfetchGateStateBlocks+attachGateStateBlocksfetchConsolidationMarkers:4021is a whole-set probe;attachEngineOwnedIntentional:3023is a non-mutating attach. Both precedents fitnow, JS derives viatoEpochMs:6382lint-engine.js:68/:82bans only the JS source text, not an agent shellingdate{state, payload, stale, skipped}, every skip loggedTwo extra checks came back clean and I am not raising them:
settledcapped at 6 matchessettledBlock:1874's ownslice(-6), andsettleDecisionfires at most twice per issue (:4444approach,:4589plan), so nothing is truncated today. Andresultsdoes carry per-issue ctx fields out of both exits (fail():2902, success return:5068), sogate_state_intentrides out exactly as claimed.Findings
[Major]
claim_authorsis self-bootstrappable at zero cost, and the rebuttal that closed this decision does not hold for a stale forged claim.## Ticketmill Claimedcomment and self-bootstrap intoclaim_authors. This does not widen the existing surface, because a forged claim already makes real runs skip the issue outright at:7528, which is a strictly worse denial of service.":7528-:7532honors a foreign claim only whenbatch != TARGETAND(now - started) < CLAIM_STALE_SECONDS; a claim withstarted: 0is stale, so the third branch fires and the real run claims the issue and proceeds normally. Step 4's race check likewise yields only to a claim that "is fresh". A stale forged claim therefore causes no DoS at all — no cost, no symptom — while still putting its author permanently intoclaim_authors, because the proposed rule collects the login of every comment whose first line isCLAIM_TITLE(:290), with no age or batch filter. The trade the rebuttal priced (pay a DoS to gain trust) is not the trade an attacker faces.## Gate Stateblock with the right repo/issue/schema. It is the newest block; positional last-wins takes it; the author filter — the only thing standing between a public comment and JS-consumed state, which is iteration 1's adopted point — accepts it. When the follow-up tier seedsctx.metrics/ctx.settled, forgedgate_budgetspresent an exhausted PR-review budget and forgedsettled[]free text lands verbatim insettledBlock(), telling every downstream gate that re-litigating it "is itself a process failure."author_loginandepoch.gh api user --jq .login, one extra line in the probe already being written. I ran it here and it returnsaaddrick. It needs no config, works for any human or bot PAT, and is not forgeable by a commenter; it is a single-object endpoint, so the plan's "never a baregh api" rule (which is about comment-list pagination) does not apply. Keepclaim_authorsas a fallback for tokens wheregh api user403s (GitHub App installation tokens), but restrict it there to claims that are fresh (< CLAIM_STALE_SECONDS) or whosebatch:equals this run'sTARGET, so a zero-cost stale forgery grants nothing.[Minor] The payload carries no group identity, so the documented fail-open is undiagnosable.
I am not re-raising the settled decision itself: iteration 1 section 7 adjudicated "do not union-fold; a re-anchor loses the primary's state and fails open to a fresh budget," and I accept that reasoning. What is new is context around it.
consolidationEnabled:1165isprofile.consolidation !== false, so grouping is on by default and this repo's profile does not disable it; andpostConsolidationMarkers:4238— the precedent this design mirrors "end to end" — posts a marker on every member issue, not only the primary. Gate state posts only onctx.issue, and the payload has nogroup_id/primary/members, so a consumer cannot tell a singleton's block from a three-issue unit's, nor "re-anchored, state sits on the old primary" from "never written." Recommendation: addgroup_idandmembersto the payload (cheap now, a contract change later) and state the member-issue gap ingate-hygiene.mdbeside the no-union-fold reasoning.[Minor] The Report sweep will report a false mismatch under a concurrent unclaimed run.
Claims fail open: the claim stage proceeds unclaimed when its agent dies, and a foreign claim flips
resume_pointonly when it is fresh. Two runs can therefore both write blocks on one issue.verifyGateStatecomparesctx.gate_state_intentagainst the newest block, so a foreign run's later post reads as a corruption alarm that is not one — and that alarm is the entire product of this stage. Recommendation: select the newest block whoserunandbatchmatch this run before comparing, and report "superseded by another run" as its own outcome rather than as a mismatch.[Minor] Two small holes in the epoch plumbing as specified.
The probe returns
nowper issue, butRUN_EPOCHis a single run-scoped value — which element wins is unspecified, and per-issue values will differ across aPromise.all. AndtoEpochMs:6382returnsNaNon an unparseable string, whichJSON.stringifysilently renders asnull, indistinguishable from "the probe died" — the one case the design wants a consumer to treat as unbounded. Recommendation: reduce explicitly (first finite value, or returnnowonce at the top level of the probe result rather than per issue) and guard withNumber.isFinite, soepochis either a real number or an explicit, loggednull.[Minor] Pin the posting idiom rather than inheriting
--body.postConsolidationMarkers:4247hands the agent a verbatim body with "gh issue comment ... --body", but every consolidation body is flatkey: valuewith no free text. Gate-state bodies carrysettled[]free text — apostrophes are routine — inside a JSON fence, and the Report-time verify exists precisely because that is where a mangle happens. Recommendation: pingh issue comment <n> --repo <r> --body-file -with a quoted heredoc (<<'GATESTATE_EOF') in the prompt, so no shell interpolation touches the payload at all.Pre-mortem
The failure I would bet on six months out is no longer mechanical: the epoch, the trust field, the last-write verify and the state table have all been argued to a good place. It is that this substrate ships advertised as trusted directly by JS, the follow-up tier consumes it on that promise, and the only gate protecting it turns out to be openable by any account willing to post two comments on a public issue — one of which the engine deliberately ignores.
aaddrick commentedon Jul 28, 2026
Proceeding After Contrarian Cap (Approach)
Iteration-2's remedies all landed and I verified each against the tree at 5715792: all four approach-loop exits are breaks (so one post after the loop covers them), phase('Report') runs on every terminal path including STOP.tripped and per-unit throws (so the run-level verify really does see each issue's final block), the separate probe matches fetchConsolidationMarkers/attachEngineOwnedIntentional, and toEpochMs plus the lint-engine bans are as described. One major remains: the adopted claim_authors trust rule can be self-bootstrapped for free, because the claim protocol ignores a STALE forged claim entirely (no DoS, the rebuttal's whole premise) while the proposed rule still whitelists its author permanently. Four minors cover missing group identity in the payload, a false-positive mismatch alarm under a concurrent unclaimed run, unspecified per-issue-to-run-scoped epoch reduction plus
aaddrick commentedon Jul 28, 2026
Implementation Plan
Summary. Ship the durable per-issue gate-state substrate as a title-gated
## Gate Stateissue comment, append-only with positional last-wins, mirroring theCONSOLIDATION_*marker subsystem end to end. Five tasks: (1) the pure JS layer plus schemas plus its unit suite, (2) the write path at three per-issue boundaries, (3) the run-level read probe plus theRUN_EPOCHderivation, (4) the Report-phase self-validation sweep that proves post -> GitHub -> read -> parse in the same run, (5) thedocs/architecture/gate-hygiene.mdsection. No consumer: nothing seedsctx.metricsorctx.settledfrom a parsed block in this issue.Full plan
Anchors (verified against the worktree at
5715792)All line numbers in the issue body predate three merged commits. Live anchors:
PREFLIGHT_SCHEMA:478CONSOLIDATION_MEMBER_TITLE/_GROUP_TITLE:994/:995buildConsolidationGroupComment/parseConsolidationGroupComment:1028/:1051settledBlock(slice(-6)):1874recordGateOutcome:2097attachEngineOwnedIntentional:3023fetchConsolidationMarkers:4021postConsolidationMarkersposting idiom:4238-:4247break):4401-:4489break):4547-:4700recordGateOutcome(ctx, 'pr-review', ...):4903fail()result assembly:2902:5068processIssuectx (settled: [],pr_review_iters: 0):5078/:5103process_prresume path:5128toEpochMs:6382TICKETMILL-TEST-HARNESS-SPLIT:6788:7172-:7201phase('Report'):7670CLAIM_TITLE/CLAIM_STALE_SECONDS/RUN_TAG:290/:292/:269The comment shape
JSON rather than consolidation's flat
key: valuelines becausesettled[]is an array of five-field objects carrying free text. The canonical marker line closes the body, exactly as every consolidation marker does.Task 1 — pure substrate + schemas + unit tests
All new functions go above the
TICKETMILL-TEST-HARNESS-SPLITmarker at:6788, beside the consolidation parsers they mirror.GATE_STATE_TITLE = '## Gate State',GATE_STATE_SCHEMA = 1,GATE_STATE_FENCEconstants.buildGateStatePayload(ctx, boundary, meta)— assembles the object above fromctx.gate_findings,ctx.metrics,ctx.settled.slice(-6)(matchingsettledBlock's own window at:1874),ctx.groupId,memberIssues(ctx).buildGateStateComment(repo, issue, payload)— title, the non-directive human line,<details>+ fenced JSON, canonical marker.JSON.stringify(payload, null, 2).parseGateStateComment(body, expect)— title-gated on the first line (an unrelated comment that merely says "gate state" is never misread), extracts the fence,JSON.parseinside try/catch, then rejects onschema !== 1,repo !== expect.repo, orissue !== expect.issue. Returnsnullon every failure path; never throws. Covers the issue's malformed-JSON, truncated-block and wrong-issue cases.selectGateState(blocks, expect)— the three-state reducer, returning{ state: 'found' | 'absent' | 'read-failed', payload, trusted, skipped: [reasons] }.read-failedis produced only when the probe reported failure (gate_state_read_ok === false), never inferred from an empty array;absentmeans the probe succeeded and no block parsed. Positional last-wins over the trusted subset; every rejected block contributes one entry toskippedso a caller can log why.isTrustedGateStateAuthor(login, trust)— contrarian major. Primary rule:login === trust.self_login, whereself_logincomes out of band from the deployment's own token (gh api user --jq .login), which a commenter cannot forge. Fallback for tokens where that 403s:trust.claim_authors, but only authors of a claim that is fresh (< CLAIM_STALE_SECONDS) or whosebatch:equals this run'sTARGET, so a zero-cost stale forged claim grants nothing.deriveRunEpoch(now)— contrarian minor.toEpochMs(now)guarded byNumber.isFinite; returns a real number or an explicitnull(never aNaNthatJSON.stringifysilently renders asnull). Caller logs thenullcase.diffGateStateIntent(intent, parsed)— canonical compare throughJSON.stringifyof both sides with sorted keys; returns'match' | 'mismatch' | 'superseded'. Contrarian minor:supersededwhen the newest block'srun/batchdo not match this run's, so a concurrent unclaimed run's later post is not reported as corruption.attachGateStateBlocks(preflights, byIssue)— non-mutating attach, shaped likeattachEngineOwnedIntentionalat:3023.GATE_STATE_PROBE_SCHEMA(blocks[] {issue, body, author_login, author_association},read_ok,total_comments,self_login,now) andGATE_STATE_VERIFY_SCHEMA({issue, comment_id, body, read_ok}). Add optionalgate_state_blocks,gate_state_read_ok,gate_state_trusttoPREFLIGHT_SCHEMAat:478— the issue asked for a field onPREFLIGHT_SCHEMAto carry the raw blocks, and it gets one; the population route differs (see task 3) and that deviation is written down in the docs task.stageOpts('probe'), an existing key in theMmap at:352, so thestageOptsdefault-to-sonnet trap the issue warns about cannot fire.tests/gate-state.test.js(new), harness-driven: build/parse round trip; a body with an apostrophe-bearingsettledentry; malformed JSON; a truncated fence; a block carrying a different issue number; a block carrying a different repo; a wrongschema; found/absent/read-failed all distinguishable; last-wins across three blocks; untrusted author rejected and stale-claim author rejected;deriveRunEpochon a good string, on garbage, and onundefined;diffGateStateIntentmatch/mismatch/superseded.node scripts/lint-engine.js --fixin the same commit (profileverify_notesLOCKSTEP-EDIT rule;.claude/workflows/ticketmill.jsis a mechanical mirror of the source this issue targets, enforced bytest_command). Thennode --test.Task 2 — the write path
postGateState(ctx, boundary), a non-fatal helper next to the cap-note stages it is modeled on. Builds the payload, stores it onctx.gate_state_intent(last write wins in memory too), and posts throughstageOpts('probe')withNOTE_SCHEMAand1retry, exactly likecap-note-plan. A dead agent orposted !== trueonlylog()s — no path may fail an issue.gh issue comment <n> --repo <r> --body-file -fed by a quoted heredoc (<<'GATESTATE_EOF'), so no shell interpolation touches a payload that routinely carries apostrophes inside free text.postConsolidationMarkersat:4247uses--bodybecause its bodies are flatkey: valuewith no free text; that inheritance is deliberately broken here.approach— immediately after the approach-gate loop closes at:4489, above the threereturn fail(...)exits at:4541/:4542/:4545. All four loop exits arebreaks, so one post covers every one.plan— once after the plan-gate loop at:4700. Same four-breakstructure.pr-review-iN— inside the pr-review loop, right afterrecordGateOutcome(ctx, 'pr-review', ...)at:4903. Deliberately not moved below the loop:reviewAndMergereturns from inside it at the both-nothing-to-fix and cap-reached breaks, and those are exactly the runs a resume needs the count for.gate_state_intentout on results: add it tofail()'s object at:2902, the success return at:5068, and theresume_point === 'skip'return, so the Report sweep can see each issue's last intended block on every terminal path.tests/pr-review-gate.test.js(or a scenario block intests/gate-state.test.js) drivingreviewAndMergethrough the stubbed-agent harness to assert one gate-state stage per pr-review iteration, that a dead gate-state stage does not change the loop's outcome, and thatctx.gate_state_intentreflects the last boundary.node --test.Task 3 — the read path
fetchGateStateBlocks(issueNumbers)atstageOpts('probe'), shaped likefetchConsolidationMarkersat:4021: ONE call over the whole set, read-only, safe underDRY_RUN. Pinsgh issue view <n> --repo <r> --json commentsper issue — never a baregh apifor the comment list, which paginates. Returns the last 3 matching blocks per issue as{body, author_login, author_association}, plusread_ok,total_comments, and two run-scoped top-level facts:self_login(gh api user --jq .login, a single-object endpoint, so the no-bare-gh apirule about pagination does not apply) andnow(date -u +%Y-%m-%dT%H:%M:%SZreturned verbatim — the exact idiom already at:7000and:7098).nowis returned once at the top level of the probe result, not per issue, so there is no unspecified per-issue reduction.RUN_EPOCH = deriveRunEpoch(probe.now)at module scope near the Select call site; anullis logged loudly. NoDate.now()anywhere —scripts/lint-engine.jsbans it and ordering is comment position, never wall clock.attachEngineOwnedIntentionalat:7221:preflights = attachGateStateBlocks(preflights, blocks). A dead probe yieldsgate_state_read_ok: falseon every preflight, whichselectGateStateturns intoread-failed, and the wiring logs one line per issue in that state. That is the issue's visible-fail-open constraint: found / absent / read-failed are three distinct values and only the third logs.absent) and a broken read (read-failed) never look the same in the transcript.read-failed; a probe returning blocks for a subset leaves the restabsent; a run whose probe returns nothing produces preflights byte-identical to today's plus the three new fields.node --test.Task 4 — the Report-phase self-validation sweep
verifyGateState(results)called fromphase('Report')at:7670, before the token/friction rollups. One stage, one round trip, for the whole run rather than 2N.## Gate Statetitle, and the pinned fetch command — never the payload, so the agent cannot echo back what it was told and pass a comparison it never performed. It returnscomment_idplus the raw body per issue.parseGateStateComment(body, expect)anddiffGateStateIntent(result.gate_state_intent, parsed), logging one ofmatch/mismatch/superseded/read-failed/no-intentper issue. A mismatch is the loud outcome the issue asks for and the whole product of this stage;supersededcovers the concurrent-unclaimed-run case rather than raising a false alarm. Non-fatal throughout: the sweep never changes a result's status.phase('Report')runs on every terminal state —STOP.trippedfillsnot_startedresults and returns rather than throwing, and a per-unit throw is isolated — so the sweep sees each issue's final block on every exit path.verifyGateState's pure comparison half over fixtures for each of the five outcomes; a dead verify stage is a no-op.node --test.Task 5 — documentation
docs/architecture/gate-hygiene.md(the file exists; do not create it) covering: the comment shape and why JSON rather than flat lines; the three boundaries and why the pr-review post stays inside the loop; append-only positional last-wins; the three-state fail-open contract and which state logs; the trust model (self_loginprimary, restrictedclaim_authorsfallback) and the stale-forged-claim reasoning behind it; the epoch derivation and theCLAIM_STALE_SECONDS(12h) bound documented as the intended staleness window with no threshold enforced at this tier; the group-identity gap — blocks post only onctx.issuewhilepostConsolidationMarkersposts on every member, so a re-anchored primary fails open to a fresh budget, which is whygroup_id/membersride in the payload; and the deviation from the issue body's "add a step to the preflight probe" (a separate whole-set probe was adopted;PREFLIGHT_SCHEMAstill carries the fields).gate-hygiene.mdrow description indocs/architecture/index.md, and the matching rows indocs/architecture/AGENTS.mdanddocs/architecture/CLAUDE.md(byte-identical parity pair).pipeline.md,metrics.md, orfailure-semantics.md— hash-frozen bytests/architecture-provenance.test.js, and the tech-docs stage runs after the last test-loop execution, so a frozen-prose edit surfaces only as a red CI check on the batch PR.CHANGELOG.mdor.claude-plugin/plugin.json:.claude/ticketmill.jsondeclares areleaseblock, so the batch release stage owns both. Every recent per-issue commit follows that split.node --test(this repo'stests/docs-links.test.js,readme-docs-census.test.jsandarchitecture-split.test.jsall police doc edits).Out of scope, restated
Nothing seeds
ctx.metricsorctx.settledfrom a parsed block, and no budget is enforced from one. That is the follow-up tier that depends on this substrate.aaddrick commentedon Jul 28, 2026
Task List
1. Pure gate-state substrate, schemas, and unit suite —
ticketmill-implementerAbove the
TICKETMILL-TEST-HARNESS-SPLITmarker (:6788), beside the consolidation parsers:GATE_STATE_TITLE/GATE_STATE_SCHEMAconstants,buildGateStatePayload,buildGateStateComment,parseGateStateComment(title-gated, fence-extracted, try/catch, rejects wrong schema/repo/issue, never throws),selectGateStatereturning{state: 'found'|'absent'|'read-failed', payload, trusted, skipped}withread-failedonly from an explicit probe failure,isTrustedGateStateAuthor(primaryself_loginfromgh api user;claim_authorsfallback restricted to fresh-or-batch-matching claims),deriveRunEpoch(toEpochMs+Number.isFinite, explicitnullneverNaN),diffGateStateIntent(match/mismatch/superseded),attachGateStateBlocks(non-mutating, shaped likeattachEngineOwnedIntentional:3023). AddGATE_STATE_PROBE_SCHEMAandGATE_STATE_VERIFY_SCHEMA, plus optionalgate_state_blocks/gate_state_read_ok/gate_state_trustonPREFLIGHT_SCHEMA:478. Newtests/gate-state.test.jscovering round trip, apostrophe-bearing free text, malformed JSON, truncated fence, wrong issue, wrong repo, wrong schema, the three states, last-wins, untrusted and stale-claim authors, epoch guards, and all three diff verdicts.node scripts/lint-engine.js --fixin the same commit;node --testgreen.2. Write path:
postGateStateplus the three boundary call sites —ticketmill-implementerNon-fatal
postGateState(ctx, boundary)modeled oncap-note-plan(stageOpts('probe'),NOTE_SCHEMA, 1 retry, log-only on failure), pinninggh issue comment <n> --repo <r> --body-file -fed by a quoted heredoc so no shell interpolation touches apostrophe-bearing payload text. Call at three boundaries: after the approach-gate loop (:4489, above the threereturn fail(...)at:4541/:4542/:4545), after the plan-gate loop (:4700), and inside the pr-review loop right afterrecordGateOutcome(ctx, 'pr-review', ...)at:4903(kept in-loop becausereviewAndMergereturns from inside it). Store the intended object onctx.gate_state_intentand thread it out throughfail():2902, the success return:5068, and theskipreturn. Harness tests: one gate-state stage per pr-review iteration, a dead stage changes no outcome,gate_state_intentreflects the last boundary. Lockstep sync;node --testgreen.3. Read path:
fetchGateStateBlocksprobe, Select wiring, andRUN_EPOCH—ticketmill-implementerNew whole-set read-only probe shaped like
fetchConsolidationMarkers:4021, pinninggh issue view <n> --repo <r> --json comments(never a baregh apifor comment lists), returning per issue the last 3 matching blocks as{body, author_login, author_association}plusread_ok/total_comments, and run-scoped top-levelself_login(gh api user --jq .login) andnow(date -u +%Y-%m-%dT%H:%M:%SZverbatim, the idiom at:7000/:7098). DeriveRUN_EPOCH = deriveRunEpoch(probe.now)once, logging anull. WireattachGateStateBlocksat Select right afterattachEngineOwnedIntentional:7221, and log one line per issue namingfound/absent/read-failedso a broken read never reads as a first run. A dead probe marks every preflightread-failed. Tests: attach is total and non-mutating, dead probe, partial coverage, and a no-block run leaving preflights otherwise unchanged. Lockstep sync;node --testgreen.4. Report-phase self-validation sweep —
ticketmill-implementerverifyGateState(results)called fromphase('Report'):7670before the rollups: one stage for the whole run whose prompt carries only the issue numbers, the literal## Gate Statetitle, and the pinned fetch command — never the payload — returningcomment_idand the raw body per issue. JS then parses and compares against each result'sgate_state_intent, loggingmatch/mismatch/superseded/read-failed/no-intent;superseded(block whoserun/batchare not this run's) is its own outcome so a concurrent unclaimed run is not reported as corruption. Non-fatal end to end; never changes a result's status. Tests over fixtures for all five outcomes plus a dead verify stage. Lockstep sync;node --testgreen.5. Document the substrate in
docs/architecture/gate-hygiene.md—ticketmill-implementerAppend a section to the existing
gate-hygiene.md: comment shape and why JSON rather than consolidation's flat lines; the three boundaries and why the pr-review post stays inside the loop; append-only positional last-wins; the three-state fail-open and which state logs; the trust model and the stale-forged-claim reasoning behind preferringself_login; the epoch derivation withCLAIM_STALE_SECONDS(12h) documented as the intended bound with no threshold enforced at this tier; the group-identity gap (blocks post only onctx.issuewhilepostConsolidationMarkers:4238posts per member) and whygroup_id/membersride in the payload; and the deviation from the issue body's "step on the preflight probe" wording. Refresh thegate-hygiene.mdrow indocs/architecture/index.mdand the parity pairAGENTS.md/CLAUDE.md. Do not touchpipeline.md,metrics.md,failure-semantics.md(hash-frozen), and do not hand-editCHANGELOG.mdor.claude-plugin/plugin.json(the batch release stage owns both perprofile.release).node --testgreen.aaddrick commentedon Jul 28, 2026
Contrarian: Plan Stress Test (Iteration 1)
Verdict: needs_rework — three majors, all narrow and all mechanical. The architecture is settled and I am not reopening it. Two of the three majors are things the approach chain adopted and the task set then dropped; the third is a harness-split placement error that would break Task 2's own required tests.
Misfiled-comment check: every comment in this trail carries the marker
<!-- ticketmill aaddrick/ticketmill#166 -->. Nothing misfiled, nothing deleted.Strengths (steel-man)
The decomposition is right. Five tasks split cleanly along the pure/impure line the harness enforces, each ends at a green
node --test, and the sequencing (substrate, write, read, verify, docs) matches the dependency order of everything except the one symbol flagged below. I re-verified the anchors that matter and they hold:attachEngineOwnedIntentional:3023,fetchConsolidationMarkers:4021, the approach loop closing at:4489with the threereturn fail(...)at:4541/:4542/:4545,recordGateOutcome(ctx, 'pr-review', ...)at:4903,toEpochMs:6382, the split marker at:6788,phase('Report'):7670.memberIssues:1091andctx.groupIdboth exist, sogroup_id/membersin the payload cost nothing new. Theskippedreturn at:5116really does assemble a result object, so threadinggate_state_intentthrough it is a one-field edit. And I checked the token-accounting worry and dropped it:reconcile_erroris pool-scoped (seeaggregateTokens'poolSpenddoc), so a Report-time sweep lands inorchestration_overheadbeside the existingclaims-releaseagent and cannot suppress the rework-tax rollup.Findings
[Major]
RUN_EPOCHis declared below the harness split, and Task 2 needs it before Task 3 exists.tests/harness.jstruncates the source atTICKETMILL-TEST-HARNESS-SPLIT:6788and evaluates only what precedes it. Task 3 placesRUN_EPOCH = deriveRunEpoch(probe.now)at the Select wiring (:7221), below the cut.buildGateStatePayload(Task 1, above the cut) reads it. Every harness test that drivespostGateState, including Task 2's own required "one gate-state stage per pr-review iteration", throwsReferenceError: RUN_EPOCH is not defined.__seed:5342cannot rescue it: it only reassigns bindings that already exist above the cut.node --testcannot be green as that task requires.epochomitted, silently reverting iteration 2's adopted staleness anchor, or Task 2 is unbuildable as written.ENGINE_OWNEDislet ENGINE_OWNED = []at:409, assigned at Select (:6879), seeded via__seed:5353. Movelet RUN_EPOCH = nullinto Task 1 beside it, addif ('RUN_EPOCH' in o) RUN_EPOCH = o.RUN_EPOCHto__seed, and leave Task 3 with the assignment only.[Major] A failed post still sets
ctx.gate_state_intent, so the Report sweep cries "mismatch" on the one path Task 2 designs for.gate_state_intentand "what is actually on the issue" stay in step.postGateState"builds the payload, stores it onctx.gate_state_intent... and posts", and "a dead agent orposted !== trueonlylog()s". On a benign post failure the intent advances while the newest block stays at the previous boundary. Task 4 then compares intent against the newest block and logsmismatch.pr-review-i3, the haiku post agent dies withSTAGE_TRIESexhausted, and thepr-review-i2block is still newest. Report announces a mismatch on an issue where nothing was mangled. The plan callsmismatch"the loud outcome the issue asks for and the whole product of this stage", so the single signal this issue ships is dominated by its own expected non-fatal path.ctx.gate_state_intentonly whenposted === true, and recordctx.gate_state_post_failed = boundaryon the failure path. Task 4 then reportspost-failedas its own outcome besidematch/mismatch/superseded/read-failed/no-intent, the same shape thesupersededfix already established for the concurrent-run false positive.[Major] The falsifiability check for
absentwas adopted at iteration 1 and appears in no task.gh issue viewis sufficient to makeabsentfalsifiable.total_commentsper issue. No task consumes it. Task 1 specifiesselectGateState(blocks, expect)withread-failedproduced "only when the probe reported failure (gate_state_read_ok === false)", and neither the signature nor the test list mentionstotal_comments,priorWork, or the impossible case. The revised evaluation adopted the opposite in writing: "blocks empty && total_comments === 0 && priorWork(pre) -> read_failed (log)", plus "the probe also returnsgate_state_total_comments, so the boolean stops being the only evidence". As planned, the sole evidence forabsentis one model-attested boolean again.fetchGateStateBlocksis ONE haiku/low call over the whole candidate set, and it must read every comment of every candidate. This trail alone is 45KB and Make review findings the load-bearing artifact #162 already carries 31 comments. On a ten-issue batch the agent reads several hundred KB to extract a handful of blocks. The realistic degradation is not a non-zero exit, it is a truncated or skipped read that still returnsread_ok: truewith an emptyblocksarray. That reports asabsent, which is precisely the silent fail-open the issue names as hard constraint (a).absentdoes not log.selectGateStatethe third argument it needs ({read_ok, total_comments}plus thepriorWorkfields already on the preflight:pr_number,worktree_exists,branch,resume_point) and implement the adopted rule. Add the two cases the evaluation named to Task 1's test list: zero blocks with zero total against prior-work evidence classifiesread-failed; zero blocks on a genuinely fresh issue staysabsent. Separately, bound the probe, either chunkingfetchGateStateBlocksor instructing it to read only the tail of each comment list, so the read does not silently degrade at batch size.[Minor]
selectGateStatequietly drops themalformedstate and thestalefallback marker.Iteration 2's finding 4 was confirmed and adopted as
{state, payload, stale, skipped}over a four-value table, with "I used an older block" distinguishable from "I used the newest block". Task 1 returns three states and nostale. An all-malformed read now reportsabsent, indistinguishable from a first run, and a malformed-tail fallback reports plainfoundcarrying an older budget.skippeddoes preserve the raw information, so this is naming rather than loss, but the contract freezes here and a consumer readingstatealone gets it wrong. Recommendation: restoremalformedandstaleas the evaluation adopted them. Two branches and two fixtures.[Minor] The
:4700anchor for the plan boundary points into the wrong loop.The plan-gate loop closes at
:4629and:4631is// ---- IMPLEMENT. Line:4700is inside the per-task implement loop, in thereviewer died — accepting provisionallybranch. A literal reading of Task 2 posts a gate-state comment once per task review instead of once per issue. The prose ("once after the plan-gate loop") is right and the number is wrong, but the anchor table claims verification: fix it to:4630or anchor on the// ---- IMPLEMENTcomment.[Minor] Two of the three boundaries have no test.
Task 2's test list covers only the pr-review loop. The approach boundary is the call site that cost iterations 2 and 3 a finding each: it must sit above the three
return fail(...)at:4541/:4542/:4545, and its entire purpose is the run where planning dies. Nothing asserts that. Recommendation: add two harness cases to Task 2, one where theplanstage returns null and exactly oneapproachblock is still posted, and one reaching IMPLEMENT that postsapproachthenplanin that order.[Minor] Nothing in the payload says whether this run's budgets are cumulative.
Every block this tier writes comes from a
ctxbuilt fresh (settled: [],pr_review_iters: 0at:5078/:5104), because there is no consumer yet. Under positional last-wins those blocks supersede richer ones, and a consumer cannot tell a block whose budgets were seeded from a prior block from one that started at zero. It fails open, so nothing breaks now, but the contract freezes here and this is the same "add the field now, retrofitting is a contract change" argument the chain used forauthor_login,epoch, andgroup_id. Recommendation: addseeded_from(therun/epochof the block this ctx was seeded from,nullwhen it was not) to the payload in Task 1.[Minor] Pin "the exact full body, never an excerpt" in both agent prompts.
Task 1 correctly title-gates on the first line and notes that "an unrelated comment that merely says 'gate state' is never misread", which is true for a verbatim body. But this trail already contains two comments quoting a
## Gate Stateblock with a fenced JSON payload naming the real repo and issue, authored by the same trusted identity, and #168's trail will contain more. The defense holds only while the probe returns whole bodies.fetchConsolidationMarkers:4026pins "its exact full body" for exactly this reason; Task 3 says only "raw verbatim bodies". Recommendation: copy the precedent's wording into both the read and the verify prompts, additionally require the canonical marker to be the last non-empty line inparseGateStateComment, and add one Task 1 fixture built from a comment that quotes the block shape, asserting it parses to null.Pre-mortem
The way this ships broken is not the design, which is now well argued. It is that the write half lands, the read half reports
absenton every issue because the whole-set probe quietly truncated, and the only stage that could have caught it is busy loggingmismatchon issues whose last post simply did not land. Both halves fail open, both are silent by construction, and the three approach iterations spent on making failure visible are undone by two omissions in the task list rather than by any decision anyone actually made.38 remaining items
aaddrick commentedon Jul 28, 2026
Test Validation (iteration 2)
PASS. Full suite green (737/737
node --test, lint-engine lockstep clean). Independently re-verified the two fixes made since iteration 1's requested change: mutatedwrite_seq: ++GATE_STATE_WRITE_SEQto a static value — exactly the new assertion ingate-state-post.test.js's "approach then plan" test fails (10/11); mutatedreadOkin the diagnostic-log branch to always-true — exactly the 4 new "...WITH prior-work evidence" variants ingate-state-read.test.jsfail (16/20), confirming both are genuinely load-bearing, not decorative.pr-review-gate.test.js's stage-key-order assertions were updated in lockstep with the newgate-state-pr-review-i*/-abortedboundary posts and strengthened, not weakened (now asserts the aborted boundary fires and the non-aborted one doesn't on a dead reviewer).gateStateProbeCommandLine's jq total-vs-blocks filter parity and theVERIFY_SKIPSpush on mismatch/read-failed (with a negative test for match/superseded) are both covered through real control flow, not string snapshots.No hollow assertions, no green-by-omission gaps found against this diff.
feat(gate-state): add pure gate-state substrate, schemas, and unit suite
feat(gate-state): add postGateState and its four write boundaries
refactor(gate-state-post-test): dedupe the gate-state-key filter into…
feat(gate-state): add fetchGateStateBlocks probe, Select-time join, a…
refactor(gate-state): dedupe hasPriorWork into hasGateStatePriorWork
feat(gate-state): add Report-phase verifyGateState self-validation sweep
docs(gate-state): document the durable per-issue gate-state substrate
fix(gate-state): gate the suspicious-absent log on an actually succes…
feat(gate-state): durable per-issue gate state on the issue substrate (…
aaddrick commentedon Jul 28, 2026
Implemented via PR #177, squash-merged into batch branch `Batch_2026-07-27_225225` (88bba37). This does not close this issue: the batch branch has not merged to `main` yet, so closure happens when a human merges the batch PR (`Batch_2026-07-27_225225` -> `main`), whose body carries the `Closes #166` reference.
Follow-ups filed from review history: #180, #181, #182.
chore(release): v0.2.0 — gate hygiene tier 1: load-bearing findings, …
aaddrick commentedon Jul 28, 2026
Landed on
mainin batch PR #183 (v0.2.0), commit 0ac64ac.