The review screen can now turn a fully approved snapshot into a GitHub merge without trusting browser-supplied SHAs or check results. The browser sends only the current review token. The trusted coordinator reloads the plan, snapshot, approvals, choices, and notes; reads GitHub state itself; and pins the merge to the reviewed head.
Derived planted-review configurations clear the source GitHub binding because their repository and review identity point at a disposable clone.
| Configuration | The merge targets | github.pullRequest |
|---|---|---|
| No runner block | github.pullRequest |
Required. The server refuses to start without it. |
| A runner block | The task's published PR (see "When the server publishes" in pull-request-opening.md) |
Optional. If it is set and names another PR, merging is blocked until it is removed. |
With a runner block, the task's PR is the opened record whose opening began last among the task's records in the configured repository with base github.baseBranch. (An older opening adopted later is not chosen; if its PR is open, it is a second open PR from the branch, which blocks merging.) Merging is blocked (pull-request) when:
- the task has no such record;
- an opening of the task's PRs is still in flight, or an update of one is (its record's
refreshfield is set); - GitHub shows the PR from another branch than the record's, from a fork, or into another base;
- the PR's description does not start with that record's marker;
- the PR is open, but another PR is open from the same branch, or GitHub's list of open PRs from the branch does not show it yet.
For this the inspection also reads the PR's description and fork flag, and lists the open PRs from the task branch (one page of up to 100; a full page fails closed). The list runs alongside the rule reads. A draft PR blocks merging (draft) in both configurations, because GitHub does not merge a draft.
The target is resolved again for each validation pass. Both passes, and the third pass in queue mode, must resolve the same PR from the same opening. Admission then re-reads the record in the same transaction that saves the attempt: it must still be the opened record whose opening began last in its base, with the same number, and nothing may be opening or updating. The attempt records the PR it merges. A status read, reconciliation or queue poll for an attempt that is submitting, queued or merged reads that PR, never the one the gate would choose now. An attempt saved before #121 has no PR: it merged github.pullRequest, so that PR is read. With a runner block and no github.pullRequest, such an attempt stays blocked until a person sets github.pullRequest to the PR it merged.
Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a cmd: acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision.
The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, GhAlreadyFixedGateway in github/already-fixed.ts, with the same rules (see “The check” in pull-request-opening.md): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check from schema v19) when that check scanned from the task's original base, and otherwise, including a first opening recorded before v19, at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, merge-handoff.md); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (MERGE_CHECK_SETTLE_MS in github/merge.ts: both grace periods of the merge's gh runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the pr-state blocker, not an already-fixed one.
Malformed pagination, rules without a type, malformed protection objects, malformed check-app identities, a classic check without app_id, and inconsistent check status/conclusion pairs fail closed. An explicit null remains an unbound check, and so does a ruleset check with no integration_id: GitHub leaves that field out for a check any app may report. A check that a ruleset or classic protection pins to an app is read as described in “Required checks pinned to an app” below.
GitHub reports a branch as protected when only a ruleset applies. Classic protection is read from branches/<b>/protection, which only a token with admin rights may read; it gives the required checks and strict. A token without admin rights got 404 "Not Found" there on every protected branch checked, so on that answer the adapter reads classic checks from the branch read (branches/<b>), which a token with read access got on the public repositories checked: protection.enabled, and protection.required_status_checks with enforcement_level, contexts and checks (with app_id). On a branch GitHub reports as protected, absence of classic protection is confirmed only by protection.enabled: false with enforcement_level off and empty lists, together with a 404 "Branch not protected" (admin rights) or "Not Found" (no admin rights). With protection.enabled: true, a "Not Found" answer takes the classic checks from the branch read: enforcement_level off with empty lists means no classic checks; non_admins or everyone means the listed checks are required, held to the same rules as the protection read's (contexts and checks both present and naming the same checks, every check with an app_id). The branch read has no strict, so classic protection read this way never supplies the atomic base guard, and codeboost's merge into a branch that only classic strict guards stays blocked (the base-guard blocker) for a token without admin rights. Any other answer on a protected branch blocks merging as unknown rules: another 404 message, a 404 that gh did not answer, enabled that is not a boolean, an unknown enforcement_level, checks listed with enforcement off, enforced checks while enabled is false, missing or disagreeing lists, and "Branch not protected" with enabled: true.
test/merge-recorded.test.ts replays GitHub's recorded answers (T12): read with an admin token, a ruleset check with no integration_id and the "Branch not protected" 404; read with a token without admin rights (2026-10-20, gh 2.102.0, public repositories; vite and svelte were recorded again on 2026-10-21, see below): a ruleset-only branch (vitejs/vite main), a classic-protected branch (cli/cli trunk), and a branch where both require one check (sveltejs/svelte main). The malformed and contradictory answers are hand-written in test/merge.test.ts. Not checked: that the branch read's checks match the protection read's (no recording has both for one branch); a token with write but not admin access, the access codeboost needs to merge (all recordings and checks without admin rights used read-only access; if such a token gets another answer, such as 403, merging stays blocked as unknown rules); a fine-grained token or a GitHub App token without the Administration permission (it may get 403 instead, which blocks merging as unknown rules); a token that cannot read checks, such as a fine-grained or GitHub App token without the Checks permission (the read of runs pinned to an app may then find no suites, so a pinned check reads as missing: merging stays blocked, after the job's full wait); a private repository read without admin rights; and GitHub Enterprise Server.
Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other gh call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging.
Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because gh pr merge can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see merge-queue.md): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a merge-queue blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking gh pr merge --match-head-commit with literal argv. Each gh process gets only the allowlisted variables in github/gh-env.ts, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the gh process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge call aborted at its 14-second deadline settles by 14.4 s. Later (#48, #170): a click can also be waiting on its issue-trust read, whose stop wait is 0.75 s, or on a review load, whose history worker gets 1 s after the abort before it is terminated; so it settles about 0.75 s after its deadline, or about 1 s or a little more after it when a review load was running, past the 14.5-second shutdown drain (traced, not tested); see merge-queue.md. Stopping gh cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (merge-queue.md). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected.
Shutdown sets a terminal admission flag before inspecting active work and checks it again after partial request bodies are received. It then stops HTTP admission, drains admitted requests, closes the pre-merge lifecycle and the Store write gate, and only then aborts and awaits an active merge CLI process (web/server.ts), so a refusal gh gave before the click was admitted, if it is handled only after the write gate closed, cannot be saved and answers a resendable 503 with the refusal's text; after admission, settlement keeps its shutdown capability and saves GitHub's refusal (#48). A request admitted before shutdown cannot start a new merge afterward, and a cancelled command cannot outlive the local state that authorized it.
A ruleset (integration_id) or classic protection (app_id) can pin a required check to one GitHub App. Then only a run that app reported counts. gh's statusCheckRollup does not say which app reported a run: gh 2.102.0 does not ask GitHub for it (#176). So the adapter asks GitHub separately, through gh api graphql.
The read happens only when the rules are known and at least one required check is pinned. It asks about the exact head that the PR read returned. GitHub does the filtering, for each pinned check:
- the check suites of the pinned app on that commit (
checkSuites(filterBy: {appId})); - in each suite, the latest run with the check's name (
checkRuns(filterBy: {checkName, checkType: LATEST})), with its status, conclusion and times. The name must match exactly, including case.
What this covers, each checked against GitHub:
- A private app. GitHub's GraphQL API shows its suite's
appasnullto a reader outside the app's owner, but the app filter still returns the suite (sveltejs/svelte'svite-ecosystem-ci; REST names the app). - A suite with many other runs. Only the pinned name is asked for, so a suite with more than 100 runs (microsoft/vscode) does not matter.
- A re-run inside one suite.
LATESTleaves out the older run (codeabovelab/codeboost PR 151:testfailed, then passed when re-run). Runs of the name in several suites are ordered as the rollup's are.
One call holds up to 25 pinned checks, so most inspections make one call. A call takes about one second when the head has a few suites from the app, and two to three seconds for a full page of 100 (measured with gh 2.102.0 on vite, cli/cli and next.js). A check whose app has more than 100 suites on the head is read in pages of 100, up to 10 pages, but the deadline ends the read first. A fresh inspection's six seconds fit about one full page after the other reads; the display inspection's 12 seconds fit three or four. Either way, a head with more suites than fit fails closed with “GitHub merge-state inspection timed out.”
Checks that any app may report are still read from the rollup alone.
No commit status meets a pinned check. GitHub may count a status that the pinned app set; this is not checked. Such a check reads as missing or pending, which fails closed.
The read fails closed:
- A gh failure keeps gh's error.
- These fail the inspection with codeboost's own reason: an answer with errors or a malformed field, an answer for another commit, a suite with more than 100 runs of the pinned name, more than 10 pages of suites, and suites that change between pages.
- The merge status shows the reason after “Could not read GitHub merge state.”
- A deadline that stops the read keeps the inspection's timeout as the reason.
- When the inspection's stages stop each other, the first stage's failure is reported, unless the inspection's deadline fires while the stopped stages settle and that failure was not gh's own exit: then the timeout is reported. Either way the read fails closed. The first failure is kept even when a stopped read then settles with its own gh error: its gh had already exited, and its output closed after the stop.
Recordings in test/fixtures/gh/ back this:
- The vite and svelte recordings were made again at the same heads on 2026-10-21 (UTC), with the pinned-check read. Vite's three checks pinned to GitHub Actions and svelte's check pinned to its private app read as passing.
pinned-rerun.json(the re-run in one suite) andpinned-large-suite.json(the suite with more than 100 runs) hold only the pinned-check read, on commits whose own rules pin nothing, and a REST read that shows the case.private-app-suites.jsonholds the svelte suite's app as GraphQL and REST give it.scripts/record-pinned-gh.tsmakes these three files again.
This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a cmd: acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still covers every base commit after the task's original base.
Unit and integration tests cover local review blockers; current and stale base/head pairs; PR state and mergeability; known, unknown, empty, pending, and failing check requirements; app-bound and malformed app identities; app-pinned checks read by app and name (passed, pending, failed, reported by another app or under another name, several per call, paged suites up to the limit) and each way that read can fail; no pinned-check read with the rules unknown; the first stage's failure reported when a stopped pinned-check read ends with its own gh exit; server base protection; merge-queue refusal for adapters without queue inspection; runtime merge-method validation; the already-fixed check run through GhAlreadyFixedGateway (closing-only cross-references, the non-default-base rule, manual links, the closed state, base-branch commits, this PR excluded only while open, the merge inputs, fail-closed results and caller cancellation); a requirement changing between validation passes; deadline cancellation; post-action cache invalidation; and the exact gh merge argv. Browser coverage proves blockers are visible, the ready action uses the reviewed head, and duplicate clicks start only one merge.