Merge gate — Revi arbitrates, determinism disposes
Revi (bots/review-pr) can post a deterministic merge-gate status on a PR so an unresolved blocking finding keeps the PR out of the merge queue — without ever letting an LLM be the yes/no arbiter of a merge.
The split is deliberate:
- Revi (LLM) proposes — one reviewer by default (
review_mode: mono), or independent Claude + GPT reviewers whenreview_mode: dualis an intentional extra spend, find issues that are normalised, de-duplicated, and posted as inline PR comments. - Determinism disposes — the bot computes a count of findings at or above a severity floor and the server posts a
revi/reviewcommit status (successwhen the count is 0, elsefailure). The gate is a count, never the review verdict. Non-blockingquestionsnever count. - A human arbitrates — a false positive is cleared by pushing a fix (which re-reviews) or, for a disputed finding, by a maintainer override (see Overriding).
This mirrors the repo's standing doctrine: gates stay deterministic (see CLAUDE.md → "Improvement loops must converge"). The reviews themselves stay non-blocking advice (forge.NewReview never approves/requests-changes); the entire gate lives in the separate commit status.
How it works
PR opened / pushed ──▶ launch claims the head: revi/review = pending
│ ("review in progress")
▼
Revi runs (selected reviewer topology → merge → publish)
│
├─ inline comments (advisory)
└─ revi/review status on the head SHA
success ⟺ 0 findings ≥ gate_severity
failure ⟺ ≥1 finding ≥ gate_severity
run dies without publishing ──▶ reconciler posts failure
(event + 1-min sweep)The context is claimed at launch and answered at the end, so the check is never silent while a review is running — see the in-flight claim and the repair.
Trigger. Revi auto-reviews on PR
opened/reopened. For the gate to track fixes, enableReviewOnSyncon the webhook so a push (synchronize) re-reviews the new head — otherwise the required check would never appear on the pushed SHA and the merge would deadlock.Verdict. The bot's deterministic
publish_reviewnode counts findings whose severity is at or abovegate_severity(defaulthigh), and sends{enabled, blocking_count, threshold, total_findings}in the publish payload.Status. The server (
/api/v1/forge/publish-review) resolves the PR head SHA and posts therevi/reviewcommit status through the team connection's live forge client (a GitHub App mints a fresh token per call — no workspace credential, no ~1h token freeze). Forge-agnostic: GitHub commit-status / GitLab commit status / Forgejo commit status all expose the same primitive (pkg/forge/status.go).Forge permission (required). Posting a commit status needs write on statuses: a GitHub App must grant Commit statuses: Read and write (a token connection needs
repo:status); GitLab/Forgejo tokens needapi/write:repository. Without itSetCommitStatusreturns 403 insufficient scope — the review still posts and the failure is reported (gate_error) + logged (forge gate: … not posted: … insufficient scope), so the gate silently advises instead of blocking. Grant the permission and re-accept the installation before relying on the gate.Enforcement. List
revi/reviewin the repo's required status checks (branch-protection ruleset). Until you do, the status is a harmless advisory check you can preview on every PR.
The status write is additive: if it fails (missing capability, forge error, no head SHA), the review still publishes and the failure is reported in the response (gate_error) and logged — never a silent no-op, never a failed publish.
Configuration
Bot vars (bots/review-pr/main.bot):
| Var | Default | Meaning |
|---|---|---|
gate_enabled | true | Post the revi/review status. false = advisory-only reviews. |
gate_severity | high | Severity floor that blocks (low<medium<high<critical). high = high+critical block; low/medium advise. |
Webhook config (pkg/webhooks/types.go):
| Field | Default | Meaning |
|---|---|---|
review_on_sync | false | Re-review on each push so the required status re-evaluates on the fixed head. Required for a blocking gate. |
block_fork_prs | false | Filter fork PRs from any auto-launch. Recommended ON whenever review_on_sync is enabled on a public repo (see caution). |
Caution — budget with
review_on_sync. The sync lane re-runs Revi's selected topology on every push (each new head SHA): one LLM reviewer in the default mono mode, two when dual is explicitly selected. It is gated only by the webhook'sAuthorAllowlist(empty = any author) and per-head idempotency — there is no author-trust gate on this lane. On a public repo a fork contributor pushing repeatedly can drive repeated full reviews, bounded only by the org launch gate + webhook rate limit. Enableblock_fork_prs(and/or set anAuthorAllowlist/MinAuthorRole) alongsidereview_on_syncso untrusted fork PRs don't auto-re-review.
Activating the blocking gate on a repo
The code posts the status unconditionally (advisory). To make it block, add the gate's context to the branch-protection ruleset's required status checks (for this repo, the "main protected — merge queue" ruleset — see merge-policy.md):
# inspect the current ruleset, add the context to required_status_checks,
# then PUT it back:
gh api repos/OWNER/REPO/rulesets/<id>Re-review on push is not something you have to remember: a status lives on one commit, so a gate that did not follow the head would leave the check absent — indistinguishable from "never reviewed" and unblockable by another review. Provisioning therefore turns review_on_sync on by itself for any repo where a co-enabled bot declares the statuses scope. The field is still settable per webhook (review_on_sync on the webhook API) for the cases the derivation does not cover.
Repo admins keep their merge-queue bypass, so a stuck gate is never a hard block for an admin.
GitHub merge queues
A merge queue tests a synthetic merge_group SHA, not the PR head that Revi reviewed. A required status posted only on the PR therefore never appears on the queue branch. This repository handles that with .github/workflows/merge-queue-gate.yml: it extracts the PR from the queue ref, reads the latest revi/review status on the PR head, and mirrors that exact state and target URL onto the queue SHA. It never invents success: no source status means no mirrored status, and a non-success verdict makes the workflow fail after publishing it.
The workflow currently names revi/review explicitly. If a repository pins a different shared gate_context, its merge-group workflow must mirror that same context (or otherwise run the gate on the merge-group SHA), or the required check will remain expected forever.
One gate, several bots
A required check applies to every pull request. So on a repo where different bots review different PRs — a dependency guard (Vetty) on the update bot's PRs, the reviewer on the humans' — neither bot's own context can be the required one: whichever bot did not run leaves the check permanently absent and blocks the PR. Requiring both is worse, since each then blocks the other's PRs.
Give them the same context instead. gate_context is a var on every bot that can gate (revi/review on Revi, vetty/deps on Vetty by default), so the repo declares one shared name and each bot fills it for the PRs it owns:
iterion remote forge repo-bots create --data '{
"connection_id": "<conn-id>",
"repo": "owner/repo",
"bot_ids": ["review-pr", "dep-update-guard"],
"launch_vars": { "gate_context": "iterion/review" }}'A review webhook is also where overlap: supersede earns its keep: with re-review on push, a burst of commits launches a run per push and the earlier ones reach a verdict about code that no longer exists. Set it on the same call ("overlap": "supersede"); it is persisted on the integration like the launch vars, so a later bots enable does not silently drop it.
Pin it through the integration's launch_vars, not the webhook's: provisioning rewrites the whole webhook config from the bots' manifests, so an override PATCHed onto the webhook is dropped at the next enable. The integration's launch vars are persisted and re-applied on every provision.
Then require iterion/review — one check, whichever bot owns the PR.
Two bots on the SAME pull request
Revi and Vetty share the context by owning disjoint PRs (author_scope: exclusive routes the update bot's PRs to one and the humans' to the other), so they never write the same status.
A fixer is different: it acts on the pull requests a reviewer already reviewed. They share the context sequentially, and the ordering is what keeps them from fighting:
- the reviewer reviews head A and posts its count on A;
- the fixer runs, pushes, and head B appears — the required check is now absent on B, which blocks the PR with nothing explaining why;
- the fixer posts its own verdict on B, immediately after its push;
- that push is a
synchronize, so withreview_on_syncon (derived ON for any repo where a bot declares thestatusesscope) the reviewer re-reviews B and its verdict — the authoritative one, from a reviewer that did not write the code — lands minutes later and supersedes.
Step 4 is deliberately exempt from the iterion-bot guard, which otherwise skips a delivery our own forge bot sent. Here the sender is the bot by construction — it is the fixer that pushed — and this is the one delivery the gate cannot lose.
Step 3 is the one that needs care, because the fixer wrote the code it is grading. Three rules keep it honest, and a fixer that gates must implement all three:
- The verdict is a count, never a judgement. Findings not fixed, plus the deterministic build gate, plus its own re-review of the diff.
- A contested finding still blocks. A fixer may argue a finding is wrong — in the open, with its reasoning, on the PR — but the argument goes to a human, never to the gate. If a refusal could green the check, a fixer would clear any review by contesting every finding.
- It speaks only for the revision it produced. Pushed nothing → post nothing, or it overwrites a verdict it has no standing to replace. Pushed code that no review has read → not green, since there is no review of that revision to report.
A green from a fixer says so in its description, so it is never mistaken for an independent review.
Step 3 needs two things the bot cannot mint for itself: a forge-publish grant and the repo's pinned gate_context. The server resolves both from the repo's integration on every lane that targets a pull request — the webhook tail, the studio/API launch, and the cloud board coordinator, which claims a card long after the webhook that created it is gone. Without them the run pushes and reports no forge publish grant on this run: no verdict, no gate, and the required check left on the pre-push revision, which blocks the PR on a check that is absent rather than red. If a fixer posts nothing, look at the run's inputs for forge_publish_url and gate_context before looking at the bot.
Zero-touch: letting a red gate launch the fixer itself (opt-in)
By default nothing happens when the gate goes red: the findings are on the pull request and the developer decides — fix them, argue one, or hand the work over with a /command. That is deliberate. A reviewer already leaves the human in the middle, and making the hand-over automatic everywhere removes that choice from every developer on the repo to save one comment.
A repo that wants the loop closed anyway turns it on per repo:
iterion remote forge repo-bots create --data '{
"connection_id": "<conn-id>",
"repo": "owner/repo",
"bot_ids": ["review-pr", "branch-improve-loop"],
"launch_vars": { "gate_context": "iterion/review" },
"auto_fix_on_gate_failure": true }'Then a review that leaves iterion/review red launches the repo's fixer on that head, with no command typed. The fixer is not named anywhere: it is whichever enabled bot declares consumes: kind: review, since that declaration already means "I start from a review and act on it".
What bounds it. Two limits, because one is not enough:
- One attempt per head sha. The fixer pushes → the head moves → a re-review produces a fresh verdict → a new attempt becomes available. A fixer that pushes nothing leaves the head where it is, and the claim on that head is already spent, so the loop ends there.
- Five passes per pull request. That first bound only stops a fixer that stops pushing; one that keeps pushing without converging frees a fresh claim every cycle. After five unattended passes the lane stops and leaves the PR to a human — the
/commandroad is still open.
It also obeys the ordinary launch gate (org quota, cost cap, concurrency) and the hold label, which pauses this lane like every other. Note the org cost cap defaults to unlimited, so it is a backstop only where you configured one.
What it refuses. It reads the verdict back from the forge rather than trusting our own bookkeeping, and abstains when the provider cannot list statuses. It acts only on the check the repo itself pinned as its gate — a run naming some other context does not qualify — only on the revision the finished run actually judged, only inside the repo the run's publish grant covers, only for a bot the webhook permits, and never on a fixer's own red verdict. Where a brake cannot be evaluated (the hold label unreadable, the attempt audit unreadable) it does not launch: an unevaluable bound is not a cleared one. Omitting auto_fix_on_gate_failure on a later call leaves the repo's current choice alone — enabling one more bot never switches automation on or off by itself.
Overriding a finding
Three ways, in order of preference:
- Push a fix — the status re-reviews on the new head (needs
review_on_sync) and flips green when the finding is gone. /revi approve [reason]— a maintainer comments this on the PR to force-green therevi/reviewstatus on the current head, for a finding they dispute. Authorized through the same PR-comment command gate as every other/command: the commenter must hold a live repo role at or aboveMinReplierRole(or be inAuthorizedRepliers), verified via the forge permission API, and the review bot's own comment can't self-approve (WhoAmI loop-guard) — an arbitrary contributor cannot wave a finding through. The status carries "approved by @user: reason" and links to the comment as the audit trail. It does not launch a re-review. (GitHub + Forgejo today; GitLab/revi approveon a note is a follow-on.)- Admin merge-queue bypass — the last resort, always available to repo admins.
Questions vs findings
Revi separates two channels:
- findings — issues it would block a merge on. These feed the board, the inline comments, and the gate count.
- questions — non-blocking, load-bearing assumptions the diff now relies on that the reviewers could not verify end-to-end ("EditorView no longer opens the file — does EditorTabHost guarantee it does?"). They make a 0-finding review falsifiable (they show what was checked and where the residual risk hides) and are surfaced in the report + the PR review summary body. They never become findings, reach the board, or gate a merge.
The check says "running" while the review runs
A review takes minutes. For all of them the gate context used to carry no status at all, and a forge renders that as "Expected — waiting for status to be reported" — the same rendering as a review that was never launched, a bot that crashed on boot, and a webhook that never fired. Read next to Revi's review comment on the previous commit, it looks exactly like "the bot commented but the gate never went green".
So the launch claims the context: the moment a run is admitted on a revision, the server posts pending on that head, described as review in progress — the verdict will replace this, pointed at the live run console. The absence of a status once again means what it says.
Two rules keep the claim from doing harm:
It never overwrites a verdict. A repo pins one gate context precisely so a required check can span several bots (below), so a second bot launching on a head another bot already judged must not blank that judgment back to "running". It writes only over nothing, over a previous claim, or over a synthetic interruption (a fresh review on that head IS the recovery). A provider iterion cannot read statuses back from is left alone.
Every consumer knows the marker, and whose it is. A guard written as "this head already has a status, so someone answered" would read the claim as a verdict — which would make posting it worse than the absence, by silencing the repair below. But the mirror error is just as bad: treating any claim as unanswered lets a dead run paint "review died" over a review that is running right now (the recovery run this repair itself launched, or a second bot sharing the repo's one context). So every status iterion writes names the run it speaks for, in its target URL, and ownership decides: its own claim is unanswered and gets repaired; another run's means somebody is working — stand down.
A corollary: with no
PublicURLconfigured a status cannot name its run, so the launch does not claim at all. The check then behaves exactly as it did before this feature — an ambiguous claim would be worse than none.
The claim is not a substitute for the repair: a run that dies still holding it leaves a pending nothing will resolve, which blocks a required check exactly like an absent one. It makes the window legible; the next section is what closes it.
A review that dies still leaves a verdict
A required check that is absent — or stuck on the in-flight claim above — is indistinguishable from one still running: the pull request waits for a context that will never arrive, and no error appears on the run, the PR or the check. That is worse than a red check, because nothing points at the cause.
It happened twice in one day in production. A rolling deploy drained a review mid-flight (the lame-duck drain is not deployed, so a rollout cancels in-flight runs). Separately, a bot bug made the publish step skip on every run, so revi/review stopped landing repo-wide and every pull request became unmergeable — with every other check green.
So the server reconciles. When a run that held a publish grant reaches a terminal state without a verdict on the PR head, it posts a failure carrying the reason and the way out, pointed at the run that owed it. Three rules keep that from doing harm of its own:
- It reads before it writes. The forge is the authority on whether the verdict landed — not any bookkeeping of ours, which a second replica would not share and a restart would lose. A provider iterion cannot read statuses back from is left alone: overwriting a real success with a synthetic failure is worse than the problem being fixed.
- It acts only where the operator pinned the gate context. Holding a publish grant is not owing a verdict: the server mints one for ANY bot launched with a
pr_url— the brancher, the docs amender, the implementer — and a repo's gate context is deliberately SHARED between the bots that gate it. The anchor is thereforelaunch_vars.gate_contexton the integration, which is already what a repo must set to make one required check span several bots. A repo that does not pin it gets no repair. (Inferring it from contexts the server had posted before was tried and dropped: that memory is empty in exactly the two situations this exists for — a bot whose publish step never succeeds, and a rollout that restarts every replica.) - It only speaks for the revision that run reviewed. The head moves while a run is alive — the author pushes a fix, a brancher commits,
review_on_syncstarts a fresh review. A newer head is a newer review's responsibility, so the run must name the revision it read (head_sha, stamped at launch) and the reconciler abstains when it does not. - It leaves paused runs and ARMED retries alone — and nothing else. A
failed_resumablerun whose usage-window retry is armed (persisted before the outcome event fires) will resume and post its own verdict. One with no retry armed — budget exceeded, retries exhausted, a plain execution failure — has nothing coming back for it and IS reconciled: skipping those left a Vetty-gated PR silently unmergeable for hours in production (2026-08-03, a 15-module go bump whose audit died on the run's own duration budget). When the retry sweeper permanently abandons a retry, it republishes the run outcome so this rule fires then too. - It stays inside the grant's scope.
pr_urlis a launch var, and the server honours a caller-pinned publish token, so the repo and forge host are re-checked against the grant exactly as the publish endpoint checks them. A red status is not a merge, but posting one on any repo a team connection reaches is precisely the blast radius the grant exists to bound. failure, notsuccess. A review that did not happen has approved nothing.
A paused run is not reconciled: it is expected to resume and post its own verdict.
Two triggers, because one event is not a guarantee
The repair is driven by a run-outcome event on the internal bus — and that bus is lossy by design. Every other consumer carries a reconciliation net for exactly that reason (usernotify's 2-minute sweep, the dispatcher's 30s poll behind its board fast path, the retry sweeper because no in-pod timer survives a rollout). The merge gate — the one consumer whose miss blocks a pull request — had none: a dropped event left the check absent forever, with the run reading failed_resumable and nothing anywhere saying a PR was waiting.
Observed 2026-08-10: four review runs died on one provider weekly cap inside 90 seconds, all four gates stayed absent for hours, and the reconciler had left no trace of having considered any of them.
So a sweep offers the same runs to the same repair a second time: every minute, terminal runs in a bounded window (a 3-minute grace so the two paths race only on the dropped ones, a 60-minute lookback so it never reaches back and paints a failure onto a long-merged PR). The repair re-reads the live status before writing, so the redundant offer costs one API read.
Telling "already answered" from "must escalate" is what makes the second offer safe. A synthetic failure deliberately does not stand the repair down — that is how a second death on one head (a relaunched run dying too) escalates instead of mistaking the first death's marker for an answer. But the same run re-offered every minute is already answered. The status's target URL names the run it speaks for, which separates the two with no bookkeeping a second replica would not share.
The sweep is not elected — the repair is idempotent by re-reading the live status, so a leader would buy nothing. One consequence needs care: the relaunch's once-per-head bound is a read-then-insert claim, so two replicas offering one dead run give a launch and a duplicate. A duplicate alone is therefore not evidence the replacement died; the board card that tells a human "automation is out of moves" is filed only once the named run has itself stopped.
Finally, when a repair genuinely declines to act, it says so. Every branch past "this run held a publish grant and died" now logs the reason it is posting nothing (forge gate: run … held a grant on … but posts nothing: …). Those branches used to return silently, which is why the four blocked PRs above were indistinguishable from four runs that gated nothing.
The verdict survives a long quota wait
A run parked on a provider usage window is resumed by the retry sweeper up to retrypolicy.DefaultMaxWait later — a weekly forfait cap resets as much as seven days out. The per-run publish grant has to outlive that: at a flat 24h it expired long before the resumed run reached its publish node, so the review completed and then had no way to post the verdict it had computed. The grant's TTL is therefore derived from the max retry wait, plus a margin for the resumed run itself.
The dead review is re-run — once per head
The synthetic failure makes the interruption visible; on an automated lane (a Dependabot PR guarded by Vetty) nobody is watching to act on it. So after posting it, the server relaunches the SAME bot on the same pull request — crash-recovery of a launch the webhook already admitted, re-run through the same tail (idempotency claim, quota metering, fresh publish grant, hold-label veto, overlap: supersede). The bound is the idempotency key itself: one relaunch per (PR, head sha), ever. The fresh run posts the real verdict over the synthetic failure when it completes.
When the one relaunch is already spent and the gate dies AGAIN on the same head — or the relaunch cannot start at all — the problem graduates to the team's board: a card labelled source:gate-reconcile (deduped per PR+head) naming the dead runs, the failure reason, and the remedy. A required check dying repeatedly on one revision is a structural signal (a run budget too short for the workload, a recurring provider quota, a bot defect), which is a human's call, and the board is where Nexie and the operator will actually see it.
The auto-fix lane (above) deliberately ignores these synthetic failures: review died means there are no findings to fix, so the recovery is re-running the REVIEWER (this lane), never launching the fixer.
