0043 — The pre-queue type check gate lives where the arm happens: pr-merge, not a disabled workflow
- Status: Accepted. Decided 2026-10-06 (Gera: "option 2"). Producer live: propflowai
.github/workflows/prequeue-typecheck.yml(#16909, fixes in #16984). Gate:~/.local/bin/pr-merge(gera-propflow/local-bin, this ADR's PR) with guard-bash rule 29/30 as the second layer.auto-merge-all.ymlstaysdisabled_manually. - Date: 2026-10-06
- Deciders: Gera, on the question raised in propflowai #16920 ("re-enable the guard, or move the gate to the local chokepoint").
- Systems:
prequeue-typecheck(new, this ADR),pr-merge/merge-actor,guard-bashrule 29, the propflowai merge queue.
The problem
2026-10-04/05: 40 merge-queue runs failed CI Checks on TypeScript errors a PR's own CI could not see. The shape, every time: the PR was green on a merge with an older main; main moved under it in a file the PR never touched; the merged tree did not type-check; the queue found it one train later and cancelled every PR behind it. #16321 cost 29 runs (live-deps.test.ts(8,13): TS2345), #15900 eleven (staff-asked-application-send.ts(108,14): TS2678). The overlap-only refresh (#16480) is blind to this by design — it fires only when main changed a file the PR also touched, and Fede's "why would I want to reduce my velocity" rules out a blanket "N behind → update-branch".
Part 1 built the producer: a dispatchable workflow that type-checks merge(head, main tip) with tsgo and stamps a prequeue-typecheck commit status on the head — <outcome> main=<sha> errors=<n>[ first=<file(l,c): TSnnnn>]; only passed is success, only failed is failure; every limitation of the gate itself (conflict, PR-authored lockfile, tracked node_modules/, crash) is error. Measured: dispatch → status in 2 min 6 s on a live PR (tsgo itself 29 s). Replayed on #15900's exact tree: failed … first=src/lib/domain/leasing/staff-asked-application-send.ts(108,14): — the queue's red, reproduced before the queue.
Part 2 wired the consumer into auto-merge-all.yml's admission path — and found that the workflow has been disabled_manually since 2026-10-01 20:48Z (the CI-cost cut) and has not run since. Every arm since then is a session's PAT running pr-merge or gh pr merge --auto. A gate on a path nothing takes is not a gate; neither was the overlap refresh or the ejected-head rule that landed there after the cut.
The decision
- The gate lives in
~/.local/bin/pr-merge, the fleet's merge verb, where the arm actually happens. ForPropFlow-Technologies/propflowaionly, before the provenance receipt: read the newestprequeue-typecheckstatus on the head; none → write apendingledger on the head, thenrepository_dispatchthe producer once, and wait for its verdict (≤ 300 s);failure→ refuse the arm (exit 5), naming the first TS error and the cure (gh pr update-branch, fix, push);success→ arm;error, an unreadable status, a request that could not be sent, no verdict within the wait → arm with a warning. Hold on exactly one thing: a real red on the merged tree.--skip-typecheck <reason>records the reason in the merge-actor ledger;--no-waitexits 4 instead of waiting. Areplay=1status (a hand replay against an old main) is a measurement, never a verdict. - guard-bash is the second layer. Rule 29 already refuses
gh pr merge --autoon propflowai at queue ≥ 29; the same rule family routes a baregh pr merge --autoon a head whoseprequeue-typecheckstatus isfailureto a refusal that namespr-merge, and warns towardpr-mergeotherwise. The hook enforces on this machine;pr-mergeenforces wherever it is run. auto-merge-all.ymlstays disabled. Re-enabling it would put a billed runner behind every CI and review completion again (the 2026-10-01 measurement: 3.4 runs per push) to host a decision the box already makes at arm time. Its admission rules (#16480's refresh, the ejected-head rule, #16920's gate) are not lost: they are the specificationpr-mergeimplements, and #16920 is closed with that note rather than merged.- The producer is unchanged and is what both layers dispatch and read. Its contract (context, description grammar,
replay=1, the-replaycontext for hand replays) is pinned byprequeue-typecheck-contract.drift.test.tsin propflowai; the gate's table is executed case by case bytests/pr-merge-refuses-a-head-whose-merged-tree-fails-the-type-check.pyin local-bin.
Why this shape
- Where the arm is, the gate is. A gate in a workflow nobody triggers is the same lie as a green check that never ran.
pr-mergeis run by the sessions that arm; the hook catches the ones that bypass it. - Fail open, hold on one thing. The cost of a wrong hold is a blocked merge verb for the whole fleet; the cost of a wrong arm is one failed train, which is today. So the gate holds only on
failure, and every failure of its own instrument arms with a warning that says so. - One check per head, ledger first. The
pendingstatus is the counter; a dispatch that fails after it still counts, so the gate cannot loop, and a head is checked once however many timespr-mergeis run on it. - No full CI re-run, no update-branch. One checker on one runner, ~2 min, only when main moved. Velocity cost is the wait, once per head.
- Stated gaps. The queue's
Build type checkis tsc over the built tree (.next/types); this gate runs tsgo over the source tree and cannot see a Next route-type error (none of the three episodes were of that kind; the nightly tsgo/tsc parity lane in ci.yml is the instrument for a checker divergence). Main can move in the ~2 min between the check and the arm; that window was hours.
Relation to other ADRs
- ADR-0003: the producer and the gate integrate by a commit status — a signal — never by a call into the workflow.
- ADR-0004: this changes how the merge verb, the merge queue and the (disabled) guard relate, so it is written at decision time.
- ADR-0009: a producer that cannot run stamps
error, not nothing; the gate's wait is bounded, so an absent verdict is read as "arm with a warning", never as a pass. - ADR-0039: the wait inside
pr-mergeis a bounded poll of one status by the session that is arming right now, not a lane waiting on CI; a--no-waitcaller gets exit 4 and comes back on its own wake.
Build
| Part | Repo | What | State |
|---|---|---|---|
| producer | propflowai | prequeue-typecheck.yml + contract test + mutation control |
propflowai#16909 (merged); hooks-off, set +e, replay isolation: propflowai#16984 |
| guard wiring (superseded) | propflowai | auto-merge-all.yml admission block + decide-prequeue-typecheck.sh |
propflowai#16920 (closed; the table it executed is the spec above) |
| gate | local-bin | pr-merge: decide / read / dispatch / wait / refuse; refusal marker; gate.json snapshot; tests |
local-bin#430, local-bin#432 |
| second layer | dotclaude | guard-bash rule 30: a bare gh pr merge --auto on a refused head is denied; every other one is nudged to pr-merge |
dotclaude#192 |
| catalog | agentflow | SYSTEMS.md entry prequeue-typecheck (file-age + json-file on gate.json) |
this PR |
State lives on the PRs, not here.
Amendment, 2026-10-15 — the external enqueue loop now exists
- Status: Accepted, amending this ADR in place (precedent: ADR-0009's amendments).
- Decided by: Gera, 2026-10-15, choosing "option (a)": build the small auto-queuer,
and do not revive
auto-merge-all.yml. ADR-0004 requires it to be written at decision time, so it ships with the loop (local-bin#599).
What was missing. Decision 3 kept auto-merge-all.yml disabled because pr-merge
"already makes the decision at arm time". But nothing ran pr-merge on its own. Fede's
retirement of the workflow (propflowai #14580..#14737) assumed "an external loop enqueues
approved + green PRs", and that loop was never built. From 2026-10-01 to 2026-10-15, a
green, approved PR reached the merge queue only if some session remembered to arm it.
What now exists. pr-autoqueue (SYSTEMS.md pr-autoqueue), a LaunchDaemon on the mini
(co.propflow.pr-autoqueue, every 300 s). It runs pr-merge --class guard --no-wait on every
open propflowai PR that meets all of these:
- 🟢 at its current head;
- green on every required check;
- based on main, not a draft, not
hold-for-review, not[HOLD]-titled, not Dependabot; - not already armed or queued.
Its cap is 29: queue entries plus PRs armed but not yet queued.
What does not change.
- The gate still lives where the arm happens (decision 1). The loop arms only through
pr-merge, so the type check, the refusal (exit 5) and the receipt apply to every arm it makes.--no-waitturns the gate's wait into "the next tick": a head with no verdict gets the check dispatched (exit 4) and is armed by a later tick once the verdict lands. auto-merge-all.ymlstays disabled (decision 3). The loop is on the box, so there is no billed runner per tick, and it reads as the App (ADR-0042).- Sessions can still arm by hand. The loop skips any PR that is already armed or queued, so the two armers never undo each other.
- The queue cap now lives in two places: guard-bash rule 29 for a session's hand arm, and the loop's own count for its arms. The authority is Fede's under-30 rule as written in propflowai CLAUDE.md. Both copies say 29 and cite it, and a change to the rule changes both.
Off switch. touch ~/.claude/jobs/pr-autoqueue/STOP stops it on the next tick without
a reload. fleet-daemon --rollback co.propflow.pr-autoqueue removes the daemon.