Skip to content
Like what we’re building? Star on GitHub

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 when review_mode: dual is 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/review commit status (success when the count is 0, else failure). The gate is a count, never the review verdict. Non-blocking questions never 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 agents/bots/bot-authoring.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 ​

Diagram: The revi/review merge gate

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  when the blocking count is 0
                                    failure  when the count is ≥1

               run dies without publishing ──▶ reconciler posts failure
                                               (event + 1-min sweep)
       run PARKS on a provider quota ──▶ check stays claimed, pause notice
                                          comments when it resumes
              pull request closed/merged ──▶ its runs stop, retries disarmed

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.

  1. Trigger. Revi auto-reviews on PR opened/reopened/ready_for_review: a draft is not reviewed when it is opened, its flip to ready is the trigger. For the gate to track fixes, enable ReviewOnSync on 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.

  2. Verdict. The bot's deterministic publish_review node counts findings whose severity is at or above gate_severity (default high), and sends {enabled, blocking_count, threshold, total_findings} in the publish payload. The count fails closed to 1 when there was nothing to count — the diff scope never resolved, or the findings were not machine-readable — and the payload's note then replaces the status description.

  3. Status. The server (/api/v1/forge/publish-review) resolves the PR head SHA and posts the revi/review commit 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 need api / write:repository. Without it SetCommitStatus returns 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.

  4. Enforcement. List revi/review in 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):

VarDefaultMeaning
gate_enabledtruePost the revi/review status. false = advisory-only reviews.
gate_severityhighSeverity floor that blocks (low<medium<high<critical). high = high+critical block; low/medium advise.

Webhook config (pkg/webhooks/types.go):

FieldDefaultMeaning
review_on_syncfalseRe-review on each push so the required status re-evaluates on the fixed head. Required for a blocking gate.

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's AuthorAllowlist (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.

Where fork PRs actually stand, per provider. The guard is unconditional on every provider and needs no configuration. The opt-in fork review lane does not change that: it is a separate SUBSCRIPTION, not a setting on these ones, and its runs can never post here — a run whose workspace holds code the tenant did not write is refused the publish grant both when it would be minted and when it would be honoured, so it cannot write a revi/review status at all. Its enforcement ships today; its admission does not, so nothing reaches it yet. See webhooks.md. On GitHub and Forgejo an inbound PR event whose head is a fork is filtered before the sync lane is even considered, so the fork re-review exposure above cannot occur there. On GitLab the inbound MR (auto-review) lane filters a proven fork the same way — the MR payload names both projects (source_project_id/target_project_id and the source project's own path), and the refusal names the fork; a payload naming neither project is not refused as a fork (unproven is not proven), and the lanes below fail closed on it. The /command lanes refuse a fork (or an unnamed head repo) too — same-repo only, silently: the fork's work needs a branch in the base repo before any bot runs on it. Every lane that resolves the MR through the API — the /command note lane, auto-fix, and gate relaunch, all fail-closed on an unproven head repo — guards on GitLab as well: a same-project MR qualifies (the MR's source and target project ids agree, so the head lives in the project the lane queried); a fork MR has its source project resolved by id (GET /projects/:id, cached per instance for an hour), so the head compares by its real path and the refusal names it; a source project the token cannot see (a private fork) stays unproven and is refused as before. The note payload itself carries neither id, which is why the /command lane resolves the MR via the API purely to prove this, even though the note already carries branch names of its own.

Review tiers — glance / guard / audit ​

A repo's criticality or budget policy can pick ONE preset (review_tier) instead of tuning five separate vars (SocialGouv/iterion#685):

Tierseverity_thresholdmax_findingspost_to_boardreview_modeReviewer model
glancehigh5falsemono onlycheaper same-family (claude-sonnet-5 / openai/gpt-5.4-mini)
guard (default)medium15truemono (auto-resolved)full-strength (claude-opus-5 / openai/gpt-5.5), unchanged since 0.7.0
auditlow40trueforced dual, regardless of review_modefull-strength, both families

guard is byte-identical to the bot's pre-#685 posture — an unpinned repo sees no behaviour change. The tier is a preset, never a cage: every knob above stays individually overridable via its own --var — a sentinel default ("auto" on the string vars, 0 on max_findings) means "let the tier decide"; any concrete value is an explicit operator override and wins, on every tier. gate_severity (the merge-blocking floor) is deliberately not tier-varied — every tier keeps the same blocking bar by default, so audit's lower severity_threshold surfaces more low/medium findings as advisory PR comments without silently making them merge-blocking. A deterministic tier_expand compute node (no LLM) resolves the concrete values right after diff_precheck, so a capped or frugal review is deterministic, not a judgment call.

The measured floor argument (why glance attacks ingestion, not just output). A day of production cost data on this repo fit cost ≈ $2.24 + $0.00072 × added lines — between +39 and +210 lines (a 5× size range), cost moved by only $0.60. Below roughly 500 lines the floor dominates: what a reviewer ingests before it reads a single diff line (a claude_code node's context injection — this repo's own CLAUDE.md/AGENTS.md — the plausible reason iterion's own floor ≈ $2.24 against code-du-travail-numerique's ≈ $1.84). A tier that only caps the diff, the findings, or the max_findings ceiling cannot move a small PR below ~$2 — it is trimming the part that already costs the least. So glance attacks the floor two ways: a cheaper model (see below), and a prompt instruction telling the reviewer to skip exploratory reads beyond the diff itself (--stat + the hunks + at most one targeted grep — never a whole surrounding package). Skipping claude_code's own context-file injection is a further, larger lever this pass did NOT take — it would need a per-node setting_sources: DSL field (today ITERION_CLAUDE_CODE_SETTING_SOURCES is engine-wide only), a genuinely new capability, filed as a follow-up rather than bundled into this preset. Shipped since as ambient_context: (ADR-119): ambient_context: none on a review node is that lever.

The model-per-tier mechanism. The two fields a tier moves do not read the same things. A node's model: (with backend:, provider: and interaction_model:) IS resolved against the run's vars on the dispatch path — {{vars.x}} first, then ${ENV_VAR:-default}, on every backend including claw's in-process path — while reasoning_effort: resolves the environment form ALONE (ir.ResolveEffortLiteral; its only dynamic input is an upstream _reasoning_effort edge mapping). So a --var review_tier=glance could retarget a model, but it could not carry the tier's effort floor with it, and a backend: spelled as a var stays UNDECIDED to the compiler, which disarms the C176/C177 screens that say what a node may DO (#1389: 92 refusals down to 23). The bot therefore declares two extra judge nodes, reviewer_claude_glance / reviewer_gpt_glance, pinned to the cheaper defaults, and the EXISTING topology condition router (ADR-052) picks between the full-strength and glance variant per family — the pattern to imitate for any future tier/variant knob that moves more than one route field, never a new engine branch.

Per-repo pin. review_tier is an ordinary launch var, so it is pinned exactly like gate_context or post_to_board — through the integration's launch_vars (durable across re-provisioning, generic pass-through, no new engine code):

sh
iterion remote forge repo-bots create --data '{
  "connection_id": "<conn-id>",
  "repo": "owner/repo",
  "bot_ids": ["review-pr"],
  "launch_vars": { "review_tier": "glance" }}'

On a repo already provisioned this is a reconcile: same hook, same iwh_ secret, nothing minted, so a stored CLI token runs it. A repo's first enable mints that secret and wants the studio or a session credential (cloud-cli). The same holds for the two repo-bots create recipes further down this page.

The fixer escalation rides the same lever. fixer_hint is the sentence the review summary publishes with its findings when this repo has a fixer to escalate to. Empty by default, and the line is then omitted entirely: the reviewer cannot know which repository it is reviewing, so it does not advertise a fixer on its own initiative. {finding} is replaced by the first finding's id.

sh
  "launch_vars": { "fixer_hint": "Correction : /billy ; arbitrage : /billy skip {finding} suivi du motif." }

The studio's repo detail page (/repos/:key) surfaces a three-position "Review tier" selector once review-pr is bound to that repo, writing the same launch_vars.review_tier field. A webhook-triggered launch never overrides an operator's pin — reviewPRVars / buildPRForgeCommandVars apply launchVars LAST, the same precedence every other operator pin on this bot already relies on.

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):

sh
# 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.

A paused review says so on the PR ​

A review that hits the provider's quota does not die: the runner arms a durable retry and the reconciler leaves the check alone, because that armed retry is the promise. But the check then sits on its in-flight claim, which reads exactly like a review that died — and nothing said how long to wait. So the park comments on the pull request, once per park (fired from the run-outcome event, never the every-minute sweep): what happened, the instant the retry fires, the attempt number, and the provider's own sentence. When that sentence is an account spend ceiling rather than a time window, the notice says so explicitly — a ceiling reopens when an admin raises it (or the month rolls), so the armed retries can otherwise exhaust themselves against a wall.

The notice is posted for every run that owes the gate a verdict — which includes a fixer that gates the head it pushes — but its wording is the review's ("the verdict lands here … a new push restarts it sooner"), so a parked fixer reads as a parked review, and the advice is the one thing not to do while a fixer works. Known gap, tracked as SocialGouv/iterion#650: the notice should name the parked run's role.

Symmetrically, a pull request that closes or merges ends every run bound to it and disarms their retries: an in-flight review would keep spending quota on a diff nobody will merge, and a parked one would wake hours later to comment on a dead PR. See webhooks.md. Known gap (SocialGouv/iterion#663): a redelivery already in flight at the close can re-claim the run seconds after it was stopped — check iterion remote runs list after a merge and cancel a reviver by hand.

Disabling the gate per repo — first review only, re-review on demand ​

Some repos want the opposite posture: reviews as advisory comments only, with the automatic review on MR/PR open the only automatic one and every re-review a deliberate human gesture (budget-frugal — no run per push). One operator pin buys the whole posture. On the integration's launch vars (launch_vars on the repo-bots API, or the studio integration settings):

json
{ "gate_enabled": "false" }

The same pin, from the studio. The Enable panel (Integrations → Repositories) carries a Merge gate fieldset whenever a selected bot gates merges and the repo has no integration on that connection yet: Gate merges is checked by default and sends no pin at all, Advisory only sends gate_enabled: "false" with the enable.

Once the repo is enabled the pin belongs to the integration, so the panel only reports the stored posture and names the one place that changes it — Merge gate: Advisory only — change it on the repo page. That page (/repos/:key) carries a Merge gate select — Gate merges — the bots post a merge-gate status against Advisory only — no merge-gate status — writing gate_enabled "true" / "false" into the integration's launch_vars and leaving its other pins alone. The options name the status, but the pin is the whole posture: with the gate off, provisioning also stops forcing review_on_sync, so pushes are no longer re-reviewed — for every bot co-enabled on the repo. The one thing the select cannot move is a review_on_sync an operator pinned through the webhook API (review_on_sync_pinned, below).

The repo page's select is admin-only (disabled for anyone who cannot manage the team); the Enable panel is an admin surface rather than a role-gated widget — the server refuses a member's enable with 403 admin or owner required. The select is shown on a repo whose integration the page holds, when that repo's bound bots gate merges or the integration already pins gate_enabled, so a repo pinned to advisory keeps the control that puts the gate back. And both read a stored value the way the server does (forge.GateValueDisables: absent = gate on; present = on only for true/1/yes/on, trimmed and case-insensitive), so a hand-written gate_enabled: "0" reads as Advisory only in the studio and as a disabled gate in the engine.

Two properties of the pin worth knowing before setting it:

  • it is repo-wide: the operator var layer applies to every co-enabled bot, so pinning it to quiet one gating bot also releases head-tracking for the others on that repo;
  • the release is logged at Warn at the moment it becomes definitive (a provision that drops a previously-forced review_on_sync), and any later launch-vars update that replaces the map WITHOUT the pin silently restores the gating posture (fail-safe direction — the gate follows the head again);
  • the derivation only rewrites unpinned syncs: a review_on_sync an operator set explicitly through the webhook API is provenance-pinned (review_on_sync_pinned) and never silently replaced in either direction — so per-push advisory reviews WITHOUT a gate (sync pinned true + gate_enabled: "false") is expressible and survives re-provisions.

What the pin disarms, end to end:

  • the bot's publish step skips the commit status — no verdict context ever lands on a head;
  • the server-side gate machinery never arms: the in-flight pending claim at launch, the reconciler, the sweeper and the auto-fix lane all read the SAME pin (forge.GateValueDisables) — so even the half-configured shape (gate disabled while a stale gate_context pin remains) claims nothing and paints no synthetic failure; dropping the gate_context pin as well is still the clean form;
  • provisioning stops forcing review_on_sync — and releases one it had forced earlier (pkg/forge/orchestrator.go, operatorGateDisabled). The forced sync exists solely to keep a REQUIRED check alive across pushes; with the gate off it would only burn a review per push. The pin survives re-provisions, unlike a bare review_on_sync: false PATCH on the webhook config (which the next provision's derivation would overwrite).

Re-review stays on demand through two gestures — with different hold-label postures:

  • a /revi comment on the MR/PR — exempt from the hold-label pause, like any /command: a comment is unambiguously a deliberate human trigger;
  • the forge-native "Re-request review" button on iterion's bot reviewer (see webhooks.md) — vetoed by the hold label: the forge emits the same event for a CODEOWNERS auto-request, which needs no permission from the requester and carries nothing to tell it from a click, so the lane cannot claim a command's deliberateness (the rationale lives with the lane in webhooks.md). On GitLab the publish step self-assigns the bot as an MR reviewer after each review precisely so this button exists; each click re-reviews the current head, even twice on the same head.

Removing the pin restores the gating posture: the next provision re-derives review_on_sync: true from the statuses scope.

Reading back whether the pin took. GET /api/teams/{id}/webhooks serialises the config. Read operator_launch_vars: it carries the pin verbatim (mirrored onto the config at provision) and is the authority BOTH consumers read — the review_on_sync derivation and the gate machinery's own runGateDisabled. "gate_enabled": "false" there is the answer.

Two neighbouring fields say what the pin did, with one JSON trap: they are omitempty bools, so absence means false, not "unknown".

  • review_on_sync — true = a push still re-reviews; absent = released. It is a consequence, not the pin: in the sync-pinned-true + gate_enabled: "false" shape described above (advisory reviews on every push, no gate) it reads true while the gate is off, so it answers "does a push re-review", never "is the gate armed".
  • review_on_sync_pinned — whether an explicitly-PATCHed sync is provenance-protected from the derivation (see above).

Do not read the reviewer's bot_rules.actions for this: that list is materialised from the bot manifest's invocation (resolveBotRules) and is identical either way, while the push decision reads cfg.ReviewOnSync directly (gateResync in the webhook handlers). It would report "released" on an armed gate.

Pair it with two run-time observations, which is what actually proves the posture end to end: a push's webhook delivery is recorded filtered, and the head of an open MR/PR carries no status. Query that last one with the full SHA — GitLab's statuses endpoint returns [] for an abbreviated one whether or not a status exists (measured on 19.2: full SHA → ["iterion/review"], same commit at 8 chars → []).

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.

Auto-heal, and when it stands down ​

A PR the queue ejects for a healable reason (MERGE_CONFLICT, CI_FAILURE, INVALID_MERGE_COMMIT, MERGE_CONFLICT_ERROR) dispatches the brancher bot to rebase, reconcile the branch with the new base, and push so the PR re-enters the queue — the queue detects the break, the bot repairs it, no human. The heal is bounded to one attempt per head sha, and gated on same-repo + project/author allowlist + bot-permitted like every other lane.

The heal stops the moment the queue takes the PR back (enqueued). This is not an optimisation: a heal still running past that point force-pushes the branch, and that push cancels the queue build in flight — ejecting the PR a second time, so the repair becomes the next breakage. The stop is keyed on the heal's own idempotency key at the PR's current head, which gives it two properties worth knowing:

  • a fixer a developer asked for with /billy rides a different key and is never touched;
  • a heal that has already pushed advanced the head, and that push is what re-enqueued the PR — so the enqueue it caused does not match its own key, and the run is left to finish its delivery tail.

Note what auto-heal cannot tell on its own: CI_FAILURE covers both "this branch genuinely breaks when combined with the base" and "an unrelated flaky test failed on the queue branch". In the second case the bot is dispatched against a PR with nothing to fix. If a flaky test is ejecting PRs, fix the test — the heal lane is not the place to absorb it.

DECLINED — a bot refusing its task on the merits ​

That second case is why a dispatched bot may refuse, and why the mission the heal hands it says so explicitly. Without that permission the refusal is un-returnable: measured on #682, a fixer concluded "no code issue in the diff, this is a re-queue not a fix", recorded that a queue build was in flight and that pushing would cancel it — and its mission still said push. Run to completion it would have destroyed the merge it was dispatched to protect.

The contract is one typed code, and it is bot-agnostic: a bot opts in by ending on a fail <name>: node carrying code: DECLINED, with its reason in the message:; nothing on the platform learns which bot did. DECLINED is deliberately not one of the engine's own FailureCodes — that set is the one a workflow may not mint (C248) — so any bot may adopt it.

What the platform does with it:

  • Nothing is relaunched. A refusal is an answer, not a failure to repair: the recovery run would re-derive it against the same premise, once per head, for as long as the trigger keeps firing. Refused at the one point every relaunch crosses (relaunchDeadGateRun), and in the auto-fix lane.
  • The reason reaches the pull request (<!-- iterion:fixer-declined -->). A declined run changed nothing, so without the notice the PR carries no trace of the decision at all — a red check and a bot that appears to have done nothing.

The bot's side has to make the refusal earned, not asserted, or the decline becomes an exit from hard work. Billy's decline_probe is the reference: a deterministic node comparing the repository to the state the run was handed (HEAD unmoved, working tree clean) that refuses a decline from any pass which committed or edited anything — that run ships its work through the ordinary tail instead. See bots/branch-improve-loop/main.bot.

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:

sh
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.

Switching the gate context (or any pinned launch var) ​

The integration PATCH (PATCH /api/teams/{id}/forge/repo-bots/{integration_id}) is the gesture. Its launch_vars semantics, pinned by test:

  • Absent leaves the stored map untouched — provided the two stores agree. On a diverged pair (a crashed two-store write, or a legacy repo whose pins live on only one side) a nil-map adopt is REFUSED (ErrProvisionDiverged, naming the diverging keys): a deliberate drop is indistinguishable from a stale store, so fail closed and re-run with an explicit launch_vars (echo what stays) or launch_vars_replace. bot_ids is likewise optional — a launch-vars-only patch need not echo the bot set (an empty bot_ids stays the 400 it always was: removing the last bot is the DELETE).
  • A non-nil map is the exact desired set. An empty map clears every pin (nobody writes {} meaning "change one key"). A non-empty map that would drop keys the repo pins today is refused — 400, naming each key to echo and the escape hatch: re-send with them, or with "launch_vars_replace": true for a genuine whole-map replacement. A partial write never applies silently.
  • Both stores move, enforcement first. The settings live twice — the integration reports them, the webhook config enforces them. The config is written first so a mid-write failure never leaves the report certifying settings that are not in force; the operator-settings write also reads both stores back, and any divergence — there or on the full rebuild path — fails loudly (ErrProvisionDiverged) and converges on re-run.

The PATCH is not a migration. Runs already launched keep the context they were launched with (the reconciler and the relaunch lane read it from the run's own inputs, by design — a relaunch is crash-recovery of a launch already decided), and nothing retroactively creates the new context on open PRs or closes an orphaned pending on the old one. So:

  1. Inventory first. Open PRs and their head SHAs, and the runs in flight or queued against them.
  2. Let in-flight runs finish, or cancel them. A finished run posts its verdict on the old context; a cancelled one gets the reconciler's synthetic failure there. Either way the old context is answered, not orphaned.
  3. PATCH with the full echo (the refusal names what to echo if you miss one).
  4. Re-launch the reviews explicitly — push, or comment the bot's command — so every open PR gets a verdict under the new context. Only then require the new context in the ruleset (and drop the old one).

A pending that outlives its run's publish grant on the old context is cosmetic once the new context is the required one: GitHub keeps statuses forever, and any later status post under the same context overwrites it.

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:

  1. the reviewer reviews head A and posts its count on A;
  2. the fixer runs, pushes, and head B appears — the required check is now absent on B, which blocks the PR with nothing explaining why;
  3. the fixer posts its own verdict on B, immediately after its push;
  4. that push is a synchronize, so with review_on_sync on (derived ON for any repo where a bot declares the statuses scope) 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) ​

State on the iterion repo itself: OFF since 2026-09-15. The lane was enabled here from 2026-08-28 and is turned off for cost — a fixer campaign re-runs the full build+test per pass on a shared credential. Nothing below changed as a feature; only this repo's choice did. Re-arming procedure: agents/workflow/billy.md#billy-is-paused.

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:

sh
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 /command road is still open.
  • Three launch attempts per head. A fixer that cannot even be started (a queue outage, a deploy window, a broken plugin source) spends neither of the two bounds above — the claim binds only once a launch succeeds, and the per-PR ceiling counts launched passes — so it used to be re-attempted on every sweep offer, once a minute, for as long as the run stayed in the sweep window (2026-08-26: ~90 minutes of it). The launch failure is now retried on a backoff (5 min, then 10 min) from the count the claim row itself carries (attempts / failed_at on the delivery), and after the third failure the lane files a board card labelled source:gate-autofix + a PR comment naming the failure and stops — a new head gets a fresh budget.

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.

Revi / Billy / Vetty — one gate, three roles ​

The close collaboration between the reviewer (Revi), the fixer (Billy/branch-improve-loop) and the dependency guard (Vetty/ dep-update-guard) on a shared gate is a design point, not an accident — but until it is written down in one place, the next agent re-derives it from three scattered sections. This is that place (SocialGouv/iterion#650).

What IS wired today:

  • Disjoint ownership, one context. Revi and Vetty share gate_context by owning disjoint PRs (author_scope: exclusive routes the dependency bot's own PRs to Vetty, everyone else's to Revi — above), so they never write the same status. A fixer is different: it acts SEQUENTIALLY on a PR a reviewer already reviewed, and the ordering that keeps them from fighting is what "Two bots on the SAME pull request" describes — the fixer posts its own verdict on the head it pushed, then the reviewer's re-review supersedes minutes later.

  • The blind window is short, not zero. A push is followed by nothing on the required check until review_on_sync's re-review launch claims pending on the new head — measured on PR #646 (2026-09-03): the claim landed 4 seconds after the push, twice in the same PR's lifecycle. review_on_sync derives ON automatically whenever a bot on the webhook gates merges (pkg/forge/orchestrator.go), so this is the default posture, not something each repo has to remember to enable for the loop to close.

  • Hand-off by declaration: KIND by default, optional producer binding. Revi produces a review; Billy consumes that kind with from: review-pr, so another bot's review is never substituted and no readable Revi review means an empty seed. In reply, Billy produces a review_ledger that Revi consumes by kind. The generic mechanism is documented in agents/bots/bot-authoring.md's "The ENGINE stays bot-agnostic" section and exercised end to end in revi-billy-loop.md. Adding a second reviewer or fixer is a bundle, never an engine PR; kind-only consumers can use it, while an explicit from: opts out of substitution.

  • The pause notice names the parked run's role. A run that parks on a provider quota gets a comment naming when it resumes, worded for the role the run's own manifest declares through its consumes:/produces: kinds — the reviewer's ("the verdict lands here … a new push restarts it sooner"), the fixer's ("don't push to this branch meanwhile — the run re-clones the head when it resumes"), and a neutral one for any other role — never a bot-id branch in the engine (SocialGouv/iterion#683; before it, a parked Billy read as a parked Revi, observed live on PR #646).

What is NOT wired (yet):

  • The "fixer in flight" signal is partial, and it is NOT the gate. The merge-queue auto-heal posts its own context, iterion/fix-in-flight — deliberately as success, so it can never block a merge (forge_fix_in_flight.go). A /billy pass and the zero-touch lane do not post it: from launch to first commit revi/review stays green on the OLD head and nothing on the PR says a fixer is working, so the run console remains the only signal for those two.

    The heal lane's silence on the gate is a design choice, not a gap: it publishes its revision as fix_head_sha precisely so head_sha does not also arm markGateInFlight and make it claim a required check it never answers (webhooks_github.go, pinned by TestMarkFixInFlight_NeverWritesOnTheGateContext). The consequence for a reader is the part worth remembering: the gate's pending link does not cover the heal lane, so it can never stand in for the run list. See revi-billy-loop.md's "What to expect on the PR" and (SocialGouv/iterion#664) for the tracking card on the two lanes that still have no pre-push signal.

Operator rules, one line each:

  1. Don't push to a PR while its fixer runs — his commits land on that branch; a manual push mid-run recreates the exact collision the "no in-flight signal" gap above cannot warn you about. git pull after his push before resuming any local work on the branch.
  2. /billy is an escalation from a review, not a replacement for one. It is comment-driven and costs a full fixer campaign, so whether it is the default answer to findings or a deliberate exception is the repo's call, not this page's. On iterion itself the answer is currently exception — findings are the developer's to fix through the local review loop (agents/workflow/review-and-merge.md); the fixer's mechanics and the conditions for re-arming it stay in revi-billy-loop.md.
  3. The zero-touch lane (auto_fix_on_gate_failure) makes the /billy escalation of rule 2 automatic on repos that opt in — a red revi/review launches the fixer with no comment, bounded by its own brakes. iterion remote runs list is the check before hand-fixing a red or ejected PR, whatever that lane is set to: a manual fix racing a running fixer is the same collision as rule 1. The PR's own statuses do not substitute for it — they are per-lane and none covers every fixer (above): the gate's pending link covers a reviewer and the zero-touch fixer, iterion/fix-in-flight covers the auto-heal, and a /billy pass shows nothing until its first commit. In particular, turning the lane off does NOT reduce this to "did I type /billy": the merge-queue auto-heal dispatches the same brancher bot with no comment when the queue ejects the PR for a healable reason, and it never consults auto_fix_on_gate_failure (webhooks_github.go, NeedsAutoHeal). A heal in flight force-pushes the branch.

Overriding a finding ​

Three ways, in order of preference:

  1. Push a fix — the status re-reviews on the new head (needs review_on_sync) and flips green when the finding is gone.
  2. /revi approve [reason] — a maintainer comments this on the PR (or MR note, on GitLab) to force-green the revi/review status on the current head, for a finding they dispute. Authorized through the same command gate as every other /command: the commenter must hold a live repo role of at least maintainer, verified via the forge permission API. The webhook's min_replier_role pin may RAISE that floor (pin owner and only owners may approve) and never lowers it: that pin is the talk-back floor — who may question a bot — and an operator who lowers it so reporters can ask the converse bot must not lower the merge-queue bypass with it. Role only, for the same reason: the webhook's AuthorizedRepliers allowlist (who may talk back to the bot) does not apply to a force-green. On Forgejo/Gitea, whose collaborator vocabulary is owner | admin | write | read (there is no maintain), the maintainer floor resolves to owner or admin — deliberately narrower than on GitHub, never wider. Two additional guards close self-approve loops: the review bot's own comment is rejected (WhoAmI loop-guard), and the PR author cannot approve their own PR (a maintainer must). The status carries "approved by @user: reason" and links to the comment as the audit trail. It does not launch a re-review. Works on GitHub App integrations (posts through the connection's installation token so the statuses scope is present), a GitLab/Forgejo team connection's admin client, and hand-owned webhooks with a forge_token binding. The token client serves when no connection covers the repo, and when the covering connection cannot serve the write — for exactly two reasons: its installation-token mint fails (a grant that lags the requested permissions, a rotated App key), or the installation withholds statuses:write (one created before the merge gate, or one that declined the permission: the App client re-mints without it, so its reads still work and only the status write would 403). In both cases the lane warns and reads, writes and replies through the binding; with no binding, the refusal names the withheld grant to approve on the App. Who is told what: the role gate runs before anything the lane says on the PR. A commenter it refuses — below the floor, or the bot's own comment — gets no reply; the webhook's delivery audit records the refusal. /revi approve is intercepted before any scope or route admission, so that branch is reachable by anyone who can comment, and a bot comment there would be one any drive-by could drive, N times for N comments. Past the gate the commenter is a maintainer, and every configuration refusal or forge failure (bot not enabled, no connection and no binding, withheld grant, no gate context, no head sha, a rejected status write) is told on the PR as what to fix; connection ids and the forge's own error text stay in the server log and on the audit row, never in the comment. Shipped on GitHub, Forgejo and GitLab.
  3. 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 PublicURL configured 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 (at the time the lame-duck drain was not deployed, so a rollout cancelled in-flight runs; the chart has rendered config.runner.drainMode since 3.78.0 — see docs/probes-and-graceful-shutdown.md). 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.
  • And the write is ORDERED, not just read-checked. A read-then-write is not atomic: between the reconciler's read of a dead run's pending and its synthetic failure, a fresh run's verdict can land (#1590). Every writer of a gate verdict — the publish endpoint, a deferred-verdict replay, an operator's /revi approve, and this synthetic failure — therefore claims the check in a shared verdict-order store first (Valkey across replicas, in-memory single-process), one mark per (forge, repo, sha, context) naming the newest decision. The reconciler's decision is anchored at the run's terminal instant (finished_at, never the bookkeeping updated_at), so a verdict decided after the run died always supersedes it whichever side writes first, and a post that lands under a newer decision puts that verdict back on top (reassertNewerVerdict — which never re-posts a synthetic status over a real verdict that just landed: the marker is re-derivable on a later pass, a displaced verdict is not). Without Valkey the authority is per-replica — the server says so once at boot. Two stated edges, by design: a real verdict decided before the death but posted inside the read-then-write window is lawfully overwritten by the synthetic failure (decision-time ordering is the only ordering a restart survives); and the launch's pending claim (markGateInFlight) is itself an unguarded read-then-write — self-healing, since the verdict that follows it always claims the authority and lands on top.
  • 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 therefore launch_vars.gate_context on 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_sync starts 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_resumable run 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_url is 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, not success. A review that did not happen has approved nothing.
  • The remedy has to be one that can work. The generic wording — "review died (…) — push again or comment the bot's command to re-run" — is right for an interruption and wrong for two typed outcomes, each of which gets its own:
    • a run that ended DECLINED did not die. It read its task, concluded the premise was wrong and deliberately changed nothing, so a push changes nothing either: the next dispatch reads the same premise and refuses again. The status says a bot was dispatched and declined, and routes to a human. (The relaunch lane already stood down on the code; the reconciler was the last reader still painting review died over a deliberate refusal.)

    • a run that ended BUDGET_EXCEEDED dies at the same place on the next attempt: the diff and the cap are unchanged. The status carries the spend and the cap and asks for a human reviewer or a higher budget, and the automatic relaunch stands down after the second budget death in a row (a first overrun can be a spike and still gets its one retry; a repeat is the cap being structurally short for that diff). Measured on iterion#780: three deaths at 30–36 $ against a 12 $ cap on a seven-file pull request, each one telling the developer to reproduce it.

    • a run whose verdict was REFUSED for its pin (#1586: the audited SHA was stale, or unreadable, when the publish arrived) did not die either — and the refusal itself wrote nothing, so on a repo that requires the check the PR had no path to merge at all (#1632). The endpoint records the refusal on the run's grant, and the reconciler answers the head with the refusal's own diagnosis — "review refused to certify (it audited X but the head moved to Y) — push again or comment the bot's command to re-review". When the head moved, the current head is the one answered: a real verdict or another run's in-flight claim there still stands the repair down (a fresher review owns it), and no relaunch is attempted — it would replay the run's inputs, stale head included, and audit the same superseded revision again at full price.

      The record carries its own identity (random, minted when the refusal is recorded), and the reconciler retires the record it consumed by that identity: a refusal recorded after it — a fresher episode, inside the post→clear window — is not that pass's to retire, and the identity is what tells the two apart when they agree on everything else (#2174). During a rolling upgrade the identity may be dropped: a replica older than the field rewrites the whole refusal record on its first read-modify-write of the grant. The clear then falls back to matching the record's clock, pin and check — the rule the identity replaced — so a refusal is still retired exactly once, never zero times.

A paused run is not reconciled: it is expected to resume and post its own verdict.

No verdict on a pull request that already ended ​

postGateStatus resolves the pull request before it writes, and refuses to write at all when its state is merged or closed. That head has left the merge decision: nothing consults the check any more, and the branch it describes is scheduled for deletion. This is the single point every bot's gate status crosses, which is what keeps the rule out of each bot's tail. An EMPTY state is a provider that does not report one, never a closure — the same predicate the relaunch, auto-fix and reconcile lanes use.

The publish response says so (gate_error), so a bot's tail can route on it rather than claim a verdict it did not get. A bot that needs the answer before it acts — a fixer deciding whether to push onto the branch — reads the grant's own read half, GET /api/v1/forge/pull-request?pr_url=… with the same X-Iterion-Run token, injected as the forge_pr_state_url launch var. git in the workspace cannot answer the question: a squash merge leaves the source branch present and its head no ancestor of the base, so a merged pull request reads locally as an open one. Observed in production (run 01a07840): a fixer kept working for half an hour past the squash of its pull request, pushed six commits onto the merged branch and posted revi/review=success on the pre-merge head.

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 on the ordinary pass). The repair re-reads the live status before writing, so the redundant offer costs one API read.

Every 30th pass reaches the full horizon instead — gateSweepHorizon, anchored on retrypolicy.DefaultMaxWait (8 days) — and so does the first pass of every term of the elected sweeper (below), at boot as after a hand-over. The outage this net exists for is a provider usage window, and a weekly one shuts for days: measured 2026-09-15, seven runs died on one weekly cap and two gating runs kept a pending required check for 81 hours, because every pass that could have answered them had stopped 80 hours earlier. Splitting the two cadences is what makes a horizon of days affordable — the per-minute scan stays the size of an hour.

A pass is still capped at gateSweepMaxPages × gateSweepBatch rows, and rows arrive newest-first — so a deep pass that restarted at the newest end every time would re-examine the same recent rows forever and never reach the old ones, which are precisely the batch-death runs the horizon exists for. A deep pass that runs out of budget therefore returns its cursor, and the next one resumes there; when it exhausts the window (or the cursor cannot advance) it starts fresh at the newest end. Successive deep passes descend toward the horizon instead of starving its far edge. The cursor lives in memory on the elected replica, for its term: the repair is idempotent, so a term that ends costs its successor a re-scan from the newest end and nothing else. The fast pass always starts at the newest end, so a deep pass parked in the past never delays a fresh death.

What keeps a long reach safe is not the bound but the repair's own live reads: it stands down on a closed or merged pull request, on a head that has moved since the run reviewed it, and on a check that already carries a real verdict. And what bounds it is the grant, which is why the post-run grace comes in two: forgePublishGateGrace is derived from the horizon, since a grant expiring first would turn every later pass into a guaranteed abstain that reads exactly like a net still trying — while every run that owes no verdict keeps the ordinary short forgePublishPostRunGrace. Both halves matter: forgePublishMaxTokens is sized as 1024 × days(TTL) on the argument that terminal eviction holds the steady state near "gating launches in flight", and on the in-memory registry saturation refuses a launch. The predicate that routes between the two graces is runOwesGateVerdict — the reconciler's own, read by the reaper, so the credential lives exactly as long as the thing that needs it says a repair may still happen.

Telling "already answered" from "must escalate" is what makes the second offer safe, and the answer turns on WHOSE marker is on the head. Its own — the event and the sweep racing, or the sweep re-reading its 60-minute window every minute for an hour — is already answered: the repair returns immediately, writing no status and walking no tail. Another run's is a different question, and only that case falls through: a second death on one head (a relaunched run dying too) must still walk the relaunch/escalation tail instead of mistaking the first death's marker for an answer. Even then the STATUS WRITE stands down: one marker per head is enough, and re-posting from a run the marker does not speak for is a storm — two dead runs re-pointing the target URL at themselves on every sweep tick produced 116 status writes on one head in 15 minutes (buildkit-operator#21, 2026-08-17). The status's target URL names the run it speaks for, which separates "mine" from "another's" with no bookkeeping a second replica would not share.

The sweep is elected: it runs on the one replica holding the merge-gate-sweeper lease (pkg/lease, ADR-117). The repair is idempotent — it re-reads the live status — so running it on every replica was never wrong, only N times as expensive: every offer spends forge reads from the App installation's hourly budget, and at ten replicas the sweep alone exhausted that budget every hour (#2002). The holder renews its lease every sweep interval and releases it on shutdown, so a rollout hands the sweep over within one interval; a holder that dies is outlived by the lease's TTL (three intervals) and replaced at most one interval later; every term opens with a deep pass.

A term also ends on the work failing, and what a pass is worth is the OFFERS it made, never its scan's SUCCESS: each offer that reached the forge and each one that could not, reported by the lane that made it (pkg/lease Pass, ITERION_LEASE_HANDOVER); a scan that could not be read at all is an attempt that reached nothing, and still counts as one. So a window where every run owes nothing is an idle pass — no offer, no evidence, and the lease stays where it is — while a pass whose every offer failed to reach the forge reads unreachable, and K of those in one term hand the net over to a peer. The three outcomes are separated by where an offer stops: a run that gates nothing exits on a local field read and accounts into nothing; an answer from the forge is this replica's reach whatever the answer said (a 404 or a 403 ends no term — a peer would be refused identically); only the absence of an answer, typed, is the class a peer may not share. A run the store cannot read answers for the store, not the forge, and a run an operator deleted between the scan and the offer is an answer like any other. A pass holding items from both far sides is classified by the CLASS of what it met and never by the order the scan returned its rows in — no success and one item that reached nothing reads unreachable, whichever row came first — because nothing orders those rows. A term ending another way — a step-down, a rollout, the lease lost — cancels the offer in flight, and that cancellation accounts into nothing either: this holder's own exit is not evidence about the forge, so a pass whose only item would have been that cancellation reads idle. Counting the scan's success as progress is what made this blind: every pass whose Mongo query answered read "progressed" while the pull requests the holder owed a check sat unanswered (#2825).

Two offers of one dead run can still meet — the event path runs on whichever replica its queue group picks, beside the sweep, and a lease handed over mid-stall overlaps two sweeps for the length of the stall. So the relaunch's once-per-head bound stays a read-then-insert claim, and two offers 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.

Reading it in production: the replica holding a term logs `merge-gate sweeper:

<replica> re-offering dead gating runs …` when the term opens and `lease "merge-gate-sweeper": <replica> released` when it ends; the lease itself is the `merge-gate-sweeper` document of the Mongo `leases` collection (`owner`, `expires_at`). Every replica logs one line per hour in which it sent forge requests — `forge HTTP: N requests in the hour ending 2026-09-30T15:00Z — <host> <rest|graphql> [installation <id>] <lane>=<n>, …` — when its next request arrives, and flushes the hour it was counting when it stops; a partial hour carries `(counted from …)` or `(until …, stopping)`. The `merge-gate-sweeper` lane is what the sweep spent that hour; `installation <id>` names the GitHub App installation whose token sent the request, and an entry without it was sent with another credential (a PAT, a user token, the App's own JWT for a token mint, a bot's `forge_token` binding). The count is of attempts as the forge client sends them — each redirect hop, each answer whatever its status, a rate-limited 403 included — so it is close to the budget spent, not identical: a token mint is counted and spends no REST budget.

The same hour's budget level follows on a line of its own, forge budget: lowest remaining in the hour ending 2026-09-30T15:00Z — <host> <rest|graphql>[/<resource>] installation <id>: <remaining> of <limit> at <seen> (resets <reset>), …: for each installation, API and named resource, the lowest X-RateLimit-Remaining any answer to that installation's token reported in the hour — GitHub sends it on every answer, 2xx included — when it was seen, and when that window resets. The /resource suffix appears only when an answer draws on a budget beyond the API's default one — GitHub's search budget behind a REST path — so it never names a separate budget what today's calls share. It shows an installation approaching exhaustion before the first rate-limited 403, not after. Every lane spends the installation's one budget, so the line has no lane; an answer to any other credential reports a budget the line cannot name and is left out.

The sweep's cadence — interval, lookback, deep-pass frequency — is the operator's to set without a release (ITERION_GATE_SWEEP_*, environment-variables.md); the horizon is not, being the grant's. A value that would open a gap between two windows keeps its default and warns. However long the interval, the lease stays paced by the default minute at most — a dead holder is replaced within minutes — and a new term's first pass runs at once. A deep pass reads at most 2 000 runs; a window holding more is walked over the following passes, each beside its fast pass, until it is exhausted — never left for the next deep pass to resume, which a spaced-out deep cadence would turn into runs no deep pass reaches. Only a page that fails twice in a row parks the walk until the next deep pass, so a broken page is not re-read at every tick.

A settled run is not offered again ​

Election makes the sweep's cost one replica's; what that replica spends is the next question, and most of it went on runs with nothing left to repair. Over the eight-day window of one busy repo, 95 % of the gating runs sat on a pull request already merged or closed, and each was read again every minute for an hour and on every deep pass for the whole horizon — some 440–880 forge reads per run from the one sweeping replica, against a dozen for the run's own work.

So when the reconciler finds a run settled, it writes the fact down, and the sweep reads those marks — one round-trip per page, before either lane is offered anything (ADR-118):

The reconciler foundMarkHolds
the pull request mergedmergedthe rest of the horizon
a real verdict on the head the run reviewedverdict_success / verdict_failurethe rest of the horizon
the run names no reviewed revisionunpinnedthe rest of the horizon
the pull request closed, unmerged — the run stopped on the close includedclosed6 h, then re-read — it can reopen
the head moved past the reviewed revisionhead_moved6 h, then re-read — a force-push can bring it back

A mark is bound to the run's terminal episode (its updated_at): a resumed run that ends again is looked at again. A verdict_failure run is still offered to the auto-fix lane — a red verdict is that lane's trigger — and to that lane only. The re-read of a reversible mark is what repairs a pull request reopened on the same head: the reopen launches no fresh review (its delivery shares the original launch's per-head key), so the dead run's own claim waits for it — up to 6 h, where the deep pass used to answer within 30 min. The marks live beside the publish grants (Valkey when the deployment has it, memory otherwise): their use ends with the grant's, and a mark that is lost, or a store that cannot answer, costs a re-read — the sweep then offers every run, and says so once.

The run's own verdict needs no forge read at all. The publish endpoint that posts it records it on the run's grant (verdict: head, check, state), so the reconciler knows on the event path that the verdict this run owed is on the forge, and settles it there: a run settled green costs the sweep nothing past the minute it ended. A red one stays offered to the auto-fix lane, which reads its pull request until it launches a fix — on every pass, for the horizon, when the lane refuses it (a fork's pull request). The record names the grant, not the run, so it is trusted only on a grant no second run publishes with; on a shared one — a launch that pinned the token, a fork — the reconciler reads the forge as before.

That record is also what the grant's cut-back needed. A gating run keeps its forge-write grant for the horizon, in case a repair must speak for it; once nothing will post with it, the grant drops to the ordinary post-run grace: after the run's own green verdict, after its own red verdict when the repo's auto-fix lane is off, and when the reconciler finds the pull request merged — which it reads only for a run not yet settled on a verdict. A red verdict with the lane on keeps the grant — the lane reads it to launch a fixer — and so does a run settled on a verdict found only on the forge; a later merge does not shorten either. The grant reaper's ordinary grace, at the end of a run that gates nothing, is the same cut-back. A shared grant, or one whose run is not over, is never cut back: a shared grant keeps its TTL, or the gate grace once a gating run that names its revision ends. The share and the cut-back are one decision on the grant: a pinned launch on a grant already cut back is refused (launch without the token to mint a fresh one), and a fork of one — or of a grant already gone — is warned about in the server log. The status's target URL stays the review, where reviewers land: ownership is read from the record, never from the check.

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.

A verdict the forge refuses for a while waits — it is not lost ​

A verdict whose post the forge refuses for a rate limit (GitHub answers one 403, or 429), or for a failure on its side (a 5xx, a call that timed out or was cut), is not a verdict lost: the forge says when its wait ends, or will answer a later try. Dropped, it made the run's silence read as a death once the limit lifted — the reconciler answered the head with a synthetic "review died", and the relaunch lane paid for a second review of a revision the first had already judged (#2002: 23 synthetic failures and 17 paid relaunches on 2026-09-30).

So the publish endpoint keeps such a pinned verdict on the run's grant (deferred: the gate, the review link, the pull request, when it was decided, when to retry, how many attempts it had) and answers gate_error: "deferred until <instant>: …". The instant is the forge's reset, or a backoff from 5 min doubling with each attempt — never more than an hour away, whatever the forge named. A refusal a retry would only repeat — forbidden, not found, a permission the installation withholds — is not deferred.

  • Until then, the run's silence is answered with nothing — no forge read, no synthetic failure, no relaunch, by the reconciler or the auto-fix lane: the budget a read would spend may be the one exhausted. The check keeps the run's in-flight claim, a run whose verdict waits counts as alive to the relaunch lane, and the grant is not cut back under it.
  • Once due, the verdict is posted by the deferral's own terms — its pull request and its pin, whatever the run's inputs say: a bot may pin the commit it pushed rather than the one it was launched on, or a short form of it. The attempt is booked on the grant before the post, so a grant that cannot be written is not posted from at all. A verdict that lands is recorded and, when it answers the run's own check, settled like one the endpoint posted.
  • A newer verdict is not overwritten. Every verdict the endpoint posts, fresh or replayed, first claims its check for the moment it was decided — a mark per (forge, repo, head, check) naming the newest decision and the status it posts — and a decision older than the mark is superseded: the newer one answers the head, and a run whose own verdict it supersedes is settled. Two posts in flight at once can still land out of order; the one that landed then re-reads the mark and puts the newer status back on top — up to three times, a decision claiming inside the last write is said in the log, and the next verdict posted on this head puts it back. A decision that will neither post nor wait — its post refused for good, its deferral unwritable, no revision pinned — lets go of the check, so the deferred verdicts older than it can still land; only a replica dying between its claim and its post leaves the claim standing until the mark lapses, and that run's own repair still answers the head. Decisions are ordered by the clock of the replica that took each one (the replicas' clocks are assumed synchronized far below the gap between two verdicts on one head), and a status written outside iterion (an operator's manual override) is not ordered: a replay due after it overwrites it.
  • A replay the forge still limits, or that fails on its side, waits again — 12 attempts in all, the refused original included. One refused for good (the head moved, the pull request closed) or whose attempts are spent is cleared, and the run handed back to the ordinary repair below; a run that owes no repair then has its grant cut back, as its end would have.

An unpinned verdict is not kept: posted hours later, it would land on whatever head the pull request has then.

The grant's other two bounds: the run's own end, and a mint that fails ​

A TTL sized for a seven-day quota wait is a long life for a credential that a normal review needs for minutes. So the run's terminal outcome brings the expiry forward: the same run-outcome event the reconciler consumes shortens the grant to the reconciler's own window (the sweep horizon plus a margin, and derived from it in code so the two cannot drift apart), after which nothing revisits the run and the grant has no reader left. Two shapes keep the full TTL, because something will come back and post their own verdict — a paused run, and a failed_resumable one with an armed retry. "Abandoned" is not re-derived here: the retry sweeper enforces the policy's max_wait, unsets the armed instant when it gives up, and republishes the run outcome, so the grant is shortened on that event instead.

The other bound is the mint itself. A launch whose grant cannot be registered — a saturated in-memory registry, an unreachable Valkey — is refused, not degraded: the run would claim the repo's gate context and then have no way to answer it, and the reconciler reads the grant to know where to speak, so it would abstain and the claim would never be resolved. Refusing works because the claim is posted after the launch: nothing is left on the head to release. The webhook lane marks the delivery launch_error (redelivery re-enters), the studio/API launch answers 503, and a board card is filed blocked with the reason.

Known gap — the registry is single-replica by default. Without Valkey the grants live in one pod's memory, so a restart empties them: an in-flight run's publish then answers 401, and the reconciler abstains on "its publish grant is expired or revoked". pkg/valkey is the cloud twin and is used when configured; making it the only backend (so an unknown token is a real refusal rather than a lost one) is not done.

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.

A relaunch that cannot start — the launch itself fails (a queue outage, a deploy window, the 2026-08-26 plugin-source parse error) rather than the relaunched run dying — does not spend the claim, and the sweep keeps offering the dead run every minute for an hour. Those offers are the retry, and the retry is bounded: the launch tail counts the attempts on the claim row (attempts / failed_at on the delivery), the lane retries on a backoff (5 min, then 10 min), and the third failure escalates exactly like a second death and then stops. An admission denial (org quota, cost cap, concurrency) still escalates on the first refusal — its horizon is not one the sweep window outlasts. Human-driven redeliveries carry no such budget: an operator retrying after a fix must be able to.

When the one relaunch is already spent and the gate dies AGAIN on the same head — or the relaunch cannot start within that budget — the problem graduates to the team's board: a card labelled source:gate-reconcile naming BOTH dead runs (the relaunch stamps a gate_relaunch_of launch var so its own death can name the original), the failure reasons, and the remedy. The same escalation is ALSO posted as a PR comment through the connection's review client: the board is the operator's queue, but the PR is where the people waiting on the merge look — a card alone sat unseen in a team inbox for 7 days while a security PR stayed blocked. The comment rides the card's insert, so the two travel together in both directions: a deployment with no board (no CloudBoardFor, or a team without one) gets neither, and the synthetic failure status is then the only surface carrying the interruption — worth knowing before pointing a board-less deployment at a required check. Card and comment are bounded to once per (PR, head) by a deterministic card id (UUIDv5 of team/repo/PR/head): two offers of one dead run can still race (the event path beside the elected sweep, or two sweeps across a lease hand-over), and two racers past a List-based dedup would each file the card AND each post the comment — the store's unique-id insert is what serialises them. Racing offers can also meet one relaunch still LAUNCHING: its claim row names no run until the launch returns, so a duplicate naming none is not a death for the 10 minutes after the claim (claimed_at, stamped by every attempt, a retry's included; a row written before the field existed dates its claim by its first receipt). A row still naming no run past that is a launch that died on the way — or, rarer, one whose run started but whose row was never told — and is escalated; a failed start read back by a racing loser is left to the retry budget, and a relaunch parked on an armed retry counts as alive. The launch tail books what it did — the row's run or its failure, the in-flight claims — even when its caller has gone, tries a refused row write again, and says so when it still cannot. 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.

A pull request that is closed or merged gets no synthetic failure at all: it owes nobody a verdict, and "push again to re-run the review" on work that already shipped is advice with no object. The check then keeps whatever the interrupted run's in-flight claim left on the head — which blocks nothing, and which a reopen heals, since the fresh review's claim may overwrite an in-flight marker. A provider that reports no state at all is not treated as a closure.

The synthetic failure states the remedy that applies to the run it replaces. A review that died reads review died (<reason>) — push again or comment the bot's command to re-run; a run whose queue message is parked on the dead-letter queue reads review parked on the DLQ — operator resume needed (iterion remote runs resume) instead, because a push there launches a fresh run and leaves the parked message where it is. Both are the reconciler's own marker, so both are repairable and both are ignored by the lane below.

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.

Gating bots — a verdict that acts ​

The doctrine line: a review advises, a human merges. ADR-124 amends it narrowly for gitops repositories, where the reviewer executes a POLICY rather than advising a taste call: a gating bot may approve the merge request, arm merge-when-pipeline-succeeds, and request reviewers — under six conditions, all enforced by the engine, none by the bot's good manners:

  1. The verdict is a deterministic fold over a closed vocabulary (the LLM classifies; a compute node decides; unknown, doubt and unreadable all fold to escalate).
  2. The gestures cross the publish endpoint's verdict block — no forge credential in the run workspace, no provider API spoken from a bundle (the vetty arm_automerge pattern is the legacy this retires).
  3. Every mutation is pinned to verdict.audited_sha; a moved head is a refusal, never a retarget.
  4. The gate status is the anchor: gestures execute only after the check posted on the same head, under the same decision.
  5. Capability is minted from the manifest (forge.token_scopes: approvals / merge / reviewers at write) into the run's publish grant — and stripped wholesale by the operator pin forge_publish_mutations set to anything but true (the per-repo disarm; no release, no bot redeploy).
  6. The bundle ships in mode: dry_run (comment + gate, zero verbs); enforce is an operator launch-vars pin, flipped after calibration, reversible the same way.

What the engine refuses, always: verdict gestures without an enabled gate (the check is the audit and ordering anchor), approval and arming behind a check that posted red (only requesting reviewers stays allowed — escalation is what a red verdict is for), gestures on a superseded or refused gate, gestures whose approval was requested and failed (never arm on an unconfirmed approval), and an arming without a sha (an unpinned arming is refused by contract in the forge layer itself).

The forge gestures are optional capabilities (forge.MergeApprover, forge.AutoMergeArmer, forge.PullReviewerSetter, forge.MergeabilityReader) — a provider that lacks one answers "implements no X capability" in the verdict result, which the bot's publish-health check surfaces as loudly as a gate that did not post.