0043 — The pre-queue type check gate lives where the arm happens: pr-merge, not a disabled workflow

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

  1. The gate lives in ~/.local/bin/pr-merge, the fleet's merge verb, where the arm actually happens. For PropFlow-Technologies/propflowai only, before the provenance receipt: read the newest prequeue-typecheck status on the head; none → write a pending ledger on the head, then repository_dispatch the 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-wait exits 4 instead of waiting. A replay=1 status (a hand replay against an old main) is a measurement, never a verdict.
  2. guard-bash is the second layer. Rule 29 already refuses gh pr merge --auto on propflowai at queue ≥ 29; the same rule family routes a bare gh pr merge --auto on a head whose prequeue-typecheck status is failure to a refusal that names pr-merge, and warns toward pr-merge otherwise. The hook enforces on this machine; pr-merge enforces wherever it is run.
  3. auto-merge-all.yml stays 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 specification pr-merge implements, and #16920 is closed with that note rather than merged.
  4. The producer is unchanged and is what both layers dispatch and read. Its contract (context, description grammar, replay=1, the -replay context for hand replays) is pinned by prequeue-typecheck-contract.drift.test.ts in propflowai; the gate's table is executed case by case by tests/pr-merge-refuses-a-head-whose-merged-tree-fails-the-type-check.py in local-bin.

Why this shape

Relation to other ADRs

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

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:

Its cap is 29: queue entries plus PRs armed but not yet queued.

What does not change.

  1. 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-wait turns 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.
  2. auto-merge-all.yml stays 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).
  3. 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.
  4. 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.