gate: close the inbox by supersession and mootness - #258
Conversation
`gate next` showed 164 rows against 3 open pull requests. 14 were parked and 150 were ready-to-merge, and the dominant leak was not the one the brief assumed: supersession already worked (the reducer folds runs by subject, and 107 of the log's 366 parks are discharged that way). What the log could not see is a PR ENDING. Every action gate writes is dry_run/would_merge — gate authorizes and an executor acts — so once the emitted command landed the PR, nothing ever said so and the row stood forever. Three parts. **One subject-scoped reduction** (`observe/closure.go`). The fold and the closure index are built once and consumed by the parked projection, the ready projection, the sweep's work list, and the audit metric — the extraction a follow-up named as the durable fix for gate's three independently-derived "is this park still open" notions. `TestParkDischargeAgreesWithTheInbox` pins that two of them cannot disagree. **Rows are classified, never dropped.** superseded / moot / stale (a PR still open whose head moved past the authorized SHA — owed work, not finished work, and deliberately not folded into moot). Counts always project; `-all` shows the rows. A discharged park carries no judge/resolve command, so a one-shot judgment cannot be spent on a settled question. **`gate sweep` records mootness.** `next -live` already discovered it on every invocation and threw it away; this persists the same batched open-PR read — no new GitHub client — as a `subject_closed` artifact parented to the terminal its row stands on, so the store's absent-parent guard makes a re-run a no-op. It records only what that read proves (`not_open`); the merge commit, actor, and clock are receipt/reconcile's claim to make from the platform. It is a separate verb rather than a flag on `next` because it writes, and `next -json` is on escalate serve's Slack path under a hard budget. `gate audit` reports by-judgment vs by-supersession after the chain check, without touching the exit code: 245 / 107 / 14 on the live ledger, a 29% supersession share. Verified against the live ledger read-only: `sweep -dry-run` closes 161 of 164, leaving exactly the 3 `-live` shows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude please review. Focus, given this is T3 (gate machinery — the reduction decides which merge-authorization parks the operator is shown, and
|
|
@codex review |
|
Claude finished @itsHabib's task in 6m 38s —— View job Review — gate: close the inbox by supersession and mootness
Focus Area 1 — Can a closing fact hide a LIVE park?The explicitly excluded cases are correct.
One genuine false-positive path: re-opened PR after sweep. This is the medium finding in this review. After // absorbSubjectClosed — no guard on which terminal the artifact is parented to
func (idx closureIndex) absorbSubjectClosed(a state.Artifact) {
...
idx[subjectKey(b.Repo, b.Number)] = closingFact{...} // keyed on subject, not on terminal
}If the PR is then re-opened, a new fact, finished := closed.lookup(row.Repo, row.Number) // finds the stale sbc_1 entry
if finished {
return live, append(discharged, dischargeParked(row, DischargeMoot, fact.why()))
}The new live park ( The comment in A terminal-scoped fix: in
Focus Area 2 — Subject-key collisionsAll four absorb paths guard correctly:
No degenerate key
Focus Area 3 —
|
| # | Area | Verdict |
|---|---|---|
| 1a | failed receipt excluded |
✅ correct |
| 1b | authorized_never_landed excluded |
✅ correct |
| 1c | Re-opened PR after sweep → offline false-MOOT | |
| 1d | absorbSubjectClosed ignores b.State |
ℹ️ low — sweep is the only writer |
| 2 | Subject-key collisions | ✅ all paths guarded |
| 3a | Sweep idempotency (absent-parent guard) | ✅ structural |
| 3b | Re-gate after closure in sweep | follows from 1c |
| 4 | ParkDischargeReport precedence | ✅ no double-count, no drops |
| 5 | NextRequest refactor | ✅ no silent offline demotion |
The core logic is sound and the test suite is thorough — TestParkDischargeAgreesWithTheInbox, TestSweepRecordsOnlyWhatItObserved, and TestClosureReadsReceiptAndCoverage are exactly the right invariant tests to pin. The one functional gap is the offline projection's permanent false-MOOT for a re-opened PR, which the live path handles correctly but escalate serve (offline path) does not see. Whether this is worth fixing before merge depends on how often PRs are re-opened in practice; FOLLOWUPS would be the right place to record it if deferring.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0160ac5a3e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Self-review found a real defect in the closure index, and it is the mirror image of the bug this PR exists to fix. A pull request can be closed and REOPENED — this repo's own review-cycle rule says a PR past its cap is "closed and re-opened fresh" — and the re-gated PR then parks again AFTER a sweep recorded the closure. The index was last-writer-wins by subject with no ordering, so that stale closing fact mooted the fresh park forever: a live merge-authorization question silently hidden, which is exactly the failure mode the moot class was added to prevent, running backwards. closingFact now carries its position in the log and `settles` requires it to POSTDATE the terminal it is asked to settle. Pinned in both directions: a park, a re-authorization, and the audit metric all stay live across a reopen, and a closure that legitimately postdates its terminal still moots it. Also: sweep now shares observe's tested parallel fan-out (`OpenSets`) instead of its own sequential loop — 19 repos in 1.9s rather than serially. Same result on the live ledger: 161 of 164 closed, leaving the 3 that are genuinely open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex's P1 on #258 named a consequence worth pinning separately from the fix: the sweep's work list IS the live rows, so a subject the reduction wrongly mooted could never be repaired by re-running the sweep. Assert the reopened subject is back on LiveSubjects, not merely back on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Codex's P1 was found independently in self-review and is already fixed — The fix. Codex named a consequence worth pinning on its own — "future sweeps cannot repair it because Pinned in both directions, since a fix that just disabled the moot class would also pass the first half:
Also in
|
Two items from the review panel, both in the same direction as the P1 all three reviewers found: never hide a live park. `absorbSubjectClosed` closed a subject on the mere existence of the artifact, ignoring its `state`. `sweep` is the only writer today and always writes `not_open`, but the kind is now whitelisted against the three states that actually mean finished. An unrecognised state — a future writer meaning something else, or a typo — leaves the row VISIBLE. A stale row is recoverable by looking at the screen; a hidden park is not. Also records why `receipt: superseded` is a closing fact, which the panel flagged as needing confirmation against #249. Verified against that branch: OutcomeSuperseded is "the PR merged, but at a DIFFERENT head than the one authorized" — a fact about the PR, not about the authorization. It makes the receipt a bad discharge and a perfectly good closing fact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Panel folded. All three reviewers — codex, claude, and my own adversarial pass — independently found the same P1 (the re-opened PR), which is a good sign about the finding and about the panel. 1c — re-opened PR false-MOOT: fixed (
|
|
Claude finished @itsHabib's task in 3m 27s —— View job Re-review — gate: close the inbox by supersession and mootness (fix-round 1)
Fix 1c — Ordering-based
|
| Scenario | Result |
|---|---|
| One closure (order 5) before terminal (order 10) | Not settled — 5 <= 10 ✅ |
| One closure (order 15) after terminal (order 10) | Settled — 15 > 10 ✅ |
| Two closures (order 5, then order 12) before a later terminal (order 20) | Not settled — last-writer gives {order:12}, 12 <= 20 ✅ |
| Two closures (order 5, then order 15) straddling a terminal (order 10) | Settled — last-writer gives {order:15}, 15 > 10 ✅ |
The last row covers the normal lifecycle: a PR is closed, gated once more (terminal at 10 in this example), and then stays closed. The latest closing fact (order 15, from a fresh sweep after re-close) correctly settles the new terminal. ✅
Fix 1c / 388b7b3 — Sweepability
TestReopenedPRIsNotMootedByItsOldClosure pins three sub-cases: park-after-closure stays live, authorization-after-closure stays ready, and — the subtlety Codex named — the reopened park is back on the sweep work list. The third case calls LiveSubjects directly and asserts the re-gated park appears. This is correct because LiveSubjects calls parkedRuns(terms, closed, "") which uses the fixed settles, so the ordering check removes the stale closure from the work-blocking path. ✅
Fix 1d — b.State whitelist (3c546bc)
var closingStates = map[string]bool{
ClosedNotOpen: true,
ClosedMerged: true,
ClosedAbandoned: true,
}
// ...
if b.Repo == "" || b.Number == 0 || !closingStates[b.State] {
return
}Correct and in the right direction: an unrecognised state costs a stale row, never a hidden park. Tested by TestUnrecognisedClosingStateLeavesTheRowVisible with four invalid values ("", "OPEN", "draft", "not-open"). ✅
receipt: "superseded" — documented
The comment in receiptClosings now explains the vocabulary distinction: "superseded" in #249 is a fact about the PR (merged at a different head), not about the authorization. The PR is gone either way, making it a closing fact. This was the round-1 note-to-confirm; it is confirmed and recorded. ✅
Summary
| Finding | Round 1 verdict | Round 2 verdict |
|---|---|---|
| 1c — re-opened PR false-MOOT | ✅ fixed — ordering-based settles, pinned in both directions |
|
| 1c/sweep — re-opened park unsweepable | ✅ fixed — LiveSubjects uses the same fixed settles, sub-test pins it |
|
1d — absorbSubjectClosed ignores b.State |
ℹ️ low | ✅ fixed — whitelist, fail-safe direction, tested |
receipt: "superseded" semantics |
🔍 confirm before #249 lands | ✅ confirmed and documented in-code |
| All round-1 greens (1a, 1b, 2, 3a, 4, 5) | ✅ | unchanged, still hold |
No new issues found. The ordering-based fix is correct across all four closing sources, the slice-index ordering invariant is maintained at every call site, and the tests pin both directions of the fix. The PR is ready from a round-2 review perspective.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c546bc13f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "observed_at": now().UTC().Format(time.RFC3339), | ||
| "source": sweepSource, | ||
| } | ||
| a, err := st.AppendIfAbsentParent(state.KindSubjectClosed, s.Run, s.Terminal, []string{s.Terminal}, body) |
There was a problem hiding this comment.
Revalidate the terminal before recording a closure
When a PR is reopened and re-gated while the sweep's GitHub fetch is in flight, the fetch can report it absent, a new terminal can then be appended, and this call finally appends subject_closed after that new terminal. AppendIfAbsentParent only deduplicates closures for the old terminal, while closureIndex.settles uses subject-wide log ordering, so the late closure incorrectly hides the fresh park/ready row and removes it from future sweep work. Fresh evidence beyond the earlier thread is this unlocked read-to-append race; revalidate that the parent is still the subject's newest terminal atomically, or scope subject_closed settlement to its parent.
AGENTS.md reference: cmd/gate/AGENTS.md:L153-L173
Useful? React with 👍 / 👎.
| "source": sweepSource, | ||
| } | ||
| a, err := st.AppendIfAbsentParent(state.KindSubjectClosed, s.Run, s.Terminal, []string{s.Terminal}, body) | ||
| if err == state.ErrAlreadyExists { |
There was a problem hiding this comment.
Match the wrapped duplicate sentinel with errors.Is
When two sweep processes operate on the same live terminal concurrently, the first append succeeds and the second returns fmt.Errorf("%w: ...", ErrAlreadyExists) from the store. Direct equality is therefore false, so the second sweep exits with an error instead of reporting the successfully deduplicated closure as Already; use errors.Is(err, state.ErrAlreadyExists) as the other callers do.
Useful? React with 👍 / 👎.
…bject
Two P1/P2s from codex's second round, both real, and the second exposed
a weakness in my own test.
**P1 — read-to-append race.** The sweep reads its work list, fetches
GitHub (seconds), then appends. A PR reopened and re-gated inside that
window gets a NEW terminal, and the closure lands AFTER it in the log —
mooting the fresh park, since a closing fact settles by log order. The
absent-parent guard allowed it, being keyed on the OLD terminal. This is
the same defect the ordering rule fixed, arriving through a race rather
than through history.
`recordClosed` now passes a `stillNewest` check to
AppendIfAbsentParentWhereAfterAudit, evaluated inside the store lock, so
no terminal can land between the check and the append. It reduces
through observe's shared fold (`NewestTerminal`) rather than forming a
second opinion about which terminal is current. A re-gated subject is
reported, not failed: the fresh terminal is the current truth and a
later sweep can close it on its own merits.
**P2 — the sentinel is wrapped.** The store returns
`fmt.Errorf("%w: ...", ErrAlreadyExists)`, so `err ==` silently never
fired and a correctly deduplicated concurrent sweep would have reported
as a hard failure. Now `errors.Is`, matching every other caller.
TestSweepIsIdempotent could not have caught it: the work list is the
LIVE rows, so after the first sweep there is nothing left to check and
its `Already` assertion looped over an empty slice. That vacuity is now
asserted directly, and TestSweepReportsAWrappedDuplicate reaches the
path by racing two closures on one terminal. Both new tests were
confirmed to fail against their specific bug and pass against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both findings from round 2 are real and are fixed in P1 — read-to-append race: fixedCorrect, and it is the same defect as the earlier one arriving by a different route. The ordering rule fixed history (a closure written before a later terminal); this is the same thing produced by a race: the work list is read, the GitHub fetch takes seconds, and a PR reopened and re-gated inside that window gets a new terminal — so the closure lands after it in the log and moots the fresh park. The absent-parent guard waved it through because it is keyed on the old terminal.
I took the revalidate option over scoping settlement to the parent, for the reason given on the earlier thread: A re-gated subject is reported, not failed — the fresh terminal is the current truth, and a later sweep can close it on its own merits. P2 — wrapped sentinel: fixed, and it exposed a vacuous testRight on the mechanism — the store returns The part worth naming: Both new tests were confirmed to fail against their specific bug and pass against the fix, rather than assumed to cover it.
Unrelated: CI's That is fix-round 2 of 2 — the cap. Per the repo's review-cycle discipline I stop fixing here; anything further goes to the judge as residual. |
|
@claude one final pass on the current head No code changes since your round-2 re-review beyond
This is a re-attestation of the final head, not a fix round — I'm at the 2-round cap and will take any further findings to the judge as residual. |
|
Claude finished @itsHabib's task in 2m 39s —— View job Final pass —
|
| Check | Verdict |
|---|---|
stillNewest reduces through observe.NewestTerminal (same fold) |
✅ confirmed |
| Lock covers check-to-append gap | ✅ confirmed |
errTerminalMoved is a reported outcome, not a failure |
✅ confirmed |
errors.Is for ErrAlreadyExists (wrapped sentinel) |
✅ confirmed |
len(res.Closed) - res.Regated arithmetic is sound |
✅ confirmed |
| Prior vacuous test corrected | ✅ confirmed |
No new issues at 7ecbc6b. The PR is clean at this head.
|
@claude review |
|
Claude finished @itsHabib's task in 2m 28s —— View job Final review — gate: close the inbox by supersession and mootness (
|
| Check | Verdict |
|---|---|
stillNewest reduces through observe.NewestTerminal (shared fold) |
✅ confirmed |
| Lock covers check-to-append gap | ✅ confirmed |
errTerminalMoved is reported, not a failure; return out, nil |
✅ confirmed |
Precedence of errTerminalMoved vs ErrAlreadyExists is structural |
✅ confirmed |
errors.Is for wrapped ErrAlreadyExists |
✅ confirmed |
len(res.Closed) - res.Regated arithmetic is sound |
✅ confirmed |
| Dry-run approximation is acceptable | ✅ noted, intentional |
| All prior round-2 greens unchanged | ✅ |
No new issues at 7ecbc6b. The PR is clean at this head.
|
Reviewer: claude |
Gate: parked for judgment — the residual, in writingTwo gate runs, and the queue narrowed to one thing. Run 1 (
Claude publishes as an issue comment, which carries no commit anchor, so gate takes its authority from the workflow attestation instead — and that step only fires when the triggering comment is exactly a review request. The regex requires the whole body to be That is the workflow behaving as designed — What remains — the actual residual:
That is codex's round-1 P1, and it is fixed — in The evidence that it is addressed, not merely claimed:
Stopping here by rule, not by preference. Two fix-rounds is the cap and both are spent; two gate runs is the stop threshold and both are spent. Escalation |
Resolves the two docs conflicts and one semantic conflict that the textual merge did not surface. cmd/gate/AGENTS.md and cmd/gate/CLAUDE.md conflicted as append-vs-append on the same bullet list: this branch documents inbox closure by supersession and mootness, main's #253 documents cycle accounting from outcomes and the evidence-read retry allowlist. The bullets are independent, so both sides are kept. cmd/gate/aborted_cycle_test.go merged cleanly but did not compile: main added it calling observe.NextText with the old trailing stateArg string, while this branch replaced that parameter with observe.NextRequest. Adapted the call to observe.NextRequest{}, matching how this branch's sibling cycles_preflight_test.go already spells it — same empty StateArg, so the test's meaning is unchanged. go build ./... , go vet ./cmd/gate/... and go test ./cmd/gate/... all pass on the merged tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
|
Claude finished @itsHabib's task in 1m 56s —— View job Re-attestation — gate: close the inbox by supersession and mootness (
|
| Area | Verdict |
|---|---|
Core PR logic (closure.go, sweep.go, discharge.go, inbox.go, state.go) |
✅ unchanged from 7ecbc6b, prior attestation carries |
| AGENTS.md / CLAUDE.md byte-identical pair | ✅ confirmed |
aborted_cycle_test.go adaptation ("" → NextRequest{}) |
✅ semantically equivalent |
| Merge introduced no gate-semantic changes | ✅ confirmed |
No new issues at b91e997. The PR is clean at this head.
|
Reviewer: claude |
gate nextlisted 164 rows against 3 open pull requests. Every claim below was measured against the live ledger (~/dev/gate/state/log.jsonl, 5,082 artifacts), read-only.The diagnosis differs from the brief, and it moves the fix
The 164 is 14 parked + 150 ready-to-merge, not 164 parks.
The brief's root cause — "an escalation is never discharged except by a judgment against its own run, so earlier runs' escalations stay parked forever" — is not what was happening.
parkedRunsalready folded runs by subject and kept the newest terminal, and it was doing real work: of the log's 366 escalations, 245 were discharged by judgment and 107 by supersession, leaving 14. Supersession was never the leak.The leak is a PR ending. Every action gate writes is
dry_run/would_merge— gate authorizes, an executor acts — so once the operator ran the emitted merge command and the PR landed, nothing in the log ever said so. The row stood forever. That is 150 of the 164, and it lives on the surface the brief did not name.Two facts that shaped the design:
gate next -livealready fixes the display (164 → 3, in 2.0s) and has since it shipped. It throws away what it learns on every invocation. The operator was reading the default, offline projection.escalate serveshellsgate next -jsonfor its grant lookup inside a hard budget on the Slack ack path; oneghsubprocess per distinct repo there trades a stale queue for a stranded interaction — the exact failure gate: bind decisions to a decider, record what landed, and measure the bypass #249 is fixing one commit over.So: derive closure from state, and give the live read somewhere to persist what it already knows.
1. One subject-scoped reduction (
observe/closure.go)foldSubjectTerminals+ a closure index, built once, consumed by the parked projection, the ready projection,sweep's work list, and the audit metric.This is the extraction FOLLOWUPS' "Still open (1)" named as the durable fix for gate's three independently-derived "is this park still open" notions.
TestParkDischargeAgreesWithTheInboxpins that two of them cannot report different live counts. It does not close that entry —cmdResolve's pre-check and the lockedrequireOpenEscalationcheck are write-path, still run-scoped, and pointing them at the shared reduction is a decision-path change with a wider blast radius than an observe-only PR should carry. FOLLOWUPS records exactly that split.2. Rows are classified, never dropped
supersededrepo#PRdisplaced itmootstalestaleis deliberately not folded intomoot. The live reconcile already dropped both, silently and identically; they are not the same fact, and reporting owed work as finished is the failure this PR exists to fix, inverted. Both were previously invisible:reconcileReadyLivedropped 161 rows and said nothing.Counts always project (
dischargedin JSON, printed by the text renderer);-allshows the rows with their reasons. A discharged park carries nojudge/resolvecommand — the judgment it would spend is one-shot, and spending it on a settled question is the concrete harm.3.
gate sweeprecords mootnessThe same batched open-PR read
next -liveandpreflightalready share — gate grows no second GitHub client — persisted as asubject_closedartifact parented to the terminal its row stands on, so the store's absent-parent guard makes "one closure per terminal" structural and a re-run a genuine no-op.It records only what that read proves:
not_open. Which commit landed, when, and by whom isreceipt/reconcile's claim, read back from the platform with its own clock and actor — a sweep asserting it would fabricate exactly what a receipt exists to check.TestSweepRecordsOnlyWhatItObservedfails if the body ever growsmerge_commit,actor, ormerged_at.An unread repo is UNKNOWN, never closed. Assuming closure on a failed read would delete the operator's queue on a network blip.
It is a separate verb, not a flag on
next: it writes, and Observability views are read-only and storeless. Anextthat sometimes wrote would put a store mutation behind a display flag on the Slack path.4.
gate auditreports the ratioAfter the chain check, never touching the exit code — integrity and health are different claims, and a metric that could fail an audit trains the reader to ignore audit failures.
Judgment takes precedence over supersession: a park that was answered is answered regardless of what happened to the PR afterwards.
Coordination with #249
#249 is still open, so this consumes its kinds by string literal (
kindReceipt,kindCoverage) and compiles without it.TestClosureReadsReceiptAndCoverageconstructs #249's exact body shapes — receipt outcomesmerged/superseded/abandonedclose,faileddoes not (the PR is still open); coverage's landed classes close,authorized_never_landeddoes not (it lists authorizations, not merges). If either body drifts before #249 lands, that test fails loudly instead of silently emptying the moot class.Rebase task on merge: swap the two constants for
state.Kind*. Recorded in FOLLOWUPS, along with the note thatcoverage's basis ismerged-pull-requestsand says nothing about a PR closed without merging — a real part of the ghost population, and whysweep'snot_openpredicate is not redundant withreconcile.Verification
Read-only against the live ledger; no writes to
~/dev/gate/state.The end-to-end write path is pinned in Go against a temp store instead: sweep closes the queue, is idempotent, leaves an unread repo alone,
-dry-runwrites nothing, and an open PR is untouched.gofmt,go vet,golangci-lint(0 issues),go test ./..., andgo test -race ./cmd/gate/...all clean. Guide pair byte-identical.Notes for review
dischargedis new and always present;discharge/discharge_whyareomitemptyon rows. The console forwardsgate next -json -liveverbatim and parses no fields, and the-livepath's row membership is unchanged.NextText/NextJSONtook aNextRequest. The offline/live and default/all axes multiply into four entry points and then eight; this followspreflight's existing request-struct shape and collapses 4 → 2.Fetch == nilis what makes offline the default rather than a bool nobody can read at a call site.subject_closedis provenance, not an outcome. Likegrant_neededandresolutionit sits outside the action/escalation families:countingSubjectguards on kind before anything else, so it burns no review cycle, never re-parks a run, and authorizes nothing. Its only effect is on what the inbox shows.sweepis not wired into any driver or schedule — when it runs is the operator's call.🤖 Generated with Claude Code