ADR-124 — A gating bot: merge-gate gestures as a declared capability, executed by the publish surface
- Status: accepted (2026-10-06, gitops-warden slice 1)
- Note: numbered 124 — written as 123 in parallel with the DEK-envelope ADR that merged first; renumbered before publication, so no published citation says 123 for this record
- Date: 2026-10-06
- Deciders: jo (direction), Claude (analysis)
- Relates to:
- 118-merge-gate-settle-marks-and-verdict-attribution.md — extends the verdict-order authority from commit statuses to the gestures that follow them.
pkg/forge/reviews.go(NewReview) — the doctrine line this ADR amends: reviews stay comment-only; gating becomes a separately-declared capability set.docs/merge-gate.md— gains the "Gating bots" section.- Ticket #2235 (gitops-warden), epic #1395 (the merge gate).
Context
The merge-gate doctrine to date: "iterion bots advise, they do not gate the merge." A reviewer bot posts comments and a deterministic commit status (revi/review); a human arbitrates — approves, merges, or pushes back. The gate machinery (statuses, verdict ordering, reconcile, settle, human override) makes that advice load-bearing without giving any bot a merge act.
Gitops repositories break the symmetry. The reviewer there is not advising a merge — it is executing a POLICY: "this diff changes only image tags, resource values and non-secret config; the platform offers these services managed; the merge is routine." The first customer is a product gitops repo (SIAM, on the PIC forge, DevOps team) whose MRs from product developers should merge without a human when — and only when — they are simple and platform-conformant, and should escalate to devops reviewers otherwise.
Doing that with today's surface is impossible (no approve, no merge-when-pipeline-succeeds, no reviewer request) or unsafe: the one existing auto-merge gesture, vetty's arm_automerge, speaks raw GitHub GraphQL from a bash tool inside the run workspace — GitHub-only by construction, invisible to the grant system, unkillable per repo.
Decision
A bot MAY cast an approval, arm merge-when-pipeline-succeeds, and request reviewers — when and only when:
- The verdict is a deterministic fold. The LLM classifies; a compute node folds a closed vocabulary into approve/escalate with fail-closed rules (unknown = escalate, doubt = escalate, unreadable = escalate). No gesture ever follows an LLM judgment directly.
- The gesture crosses the server publish surface.
/api/v1/forge/publish-reviewgains an optionalverdictblock; the server executes approve → reviewers → arm through the team connection's live client. No forge credential in the run workspace, no provider API spoken from a bundle. This retires the vetty pattern (kept for GitHub until vetty migrates; marked legacy). - Every mutation is pinned to the audited head (
verdict.audited_shais REQUIRED — stricter than the gate status, whose legacy unpinned post stays for bundles that predate the pin). A moved head is a refusal, never a retarget: a stale verdict certifies nothing and arms nothing. - The gate status is the anchor. Verdict gestures execute only after the gate status posted on the same head, ordered by the same gate decision. A merge a bot armed always has a check on the forge saying whose verdict armed it; a superseded or refused gate carries the gestures down with it. Verdicts without an enabled gate are refused.
- Capability is minted, not assumed. The run's publish grant carries capabilities (approve / merge / reviewers) resolved from the bot manifest's
forge.token_scopes(approvals/merge/reviewersat write level). An unresolvable manifest mints none. The operator pinforge_publish_mutations(any value buttrue) strips every gesture capability at mint — the per-repo disarm that needs no release and no bot redeploy. - Dry-run is the shipped posture. The gesture-capable bundle defaults to
mode: dry_run(comment + gate, zero verbs);enforceis an operator launch-vars pin, flipped after calibration, reversible by the same pin.
The forge layer speaks the gestures behind three optional-capability interfaces (forge.MergeApprover, forge.AutoMergeArmer, forge.PullReviewerSetter, plus forge.MergeabilityReader for the merge verdict read), implemented where the forge has a native gesture: GitLab (POST approve with sha pin, PUT merge with merge_when_pipeline_succeeds, reviewer_ids union) and GitHub (APPROVE review event, GraphQL auto-merge with expectedHeadOid, requested_reviewers). MWPS is NOT a field on MergeOptions/MergePull: arming and merging are different acts, and a bot that wants the merge arms and lets the forge gate it. There is no merge-now gesture in the set.
Arming semantics both providers share: the FORGE performs the merge when its own conditions hold (pipeline green per the repo's settings, no conflicts) — the bot never bypasses a required check by arming; the required check remains the repo's own.
Consequences
docs/merge-gate.mdgains a "Gating bots" section: the verdict block, the scopes, the kill switch, the calibration posture.- The human override (
/revi approvefamily) is unchanged and outranks a bot verdict through the existing claim authority: a bot verdict can never paint over a newer human decision, and vice versa. - A gating bot's audit trail: every executed gesture is audited with the mode that claimed to drive it; provisioning logs which bots carry gesture scopes.
- Known residuals, accepted and documented: two concurrent reviewer-union writes can drop one addition (GitLab has no atomic reviewer-add; self-healing on the next publish); a peer gate going red after the arming is a race the arm-holder's re-run repairs (stock GitLab does not block a merge on external commit statuses — the bot reads peer statuses itself through the pull-request read twin).
- Rejected: bot-side forge credentials for gestures (the vetty pattern — ungrantable, unkillable, provider-bound); merge-now (bypasses the forge's own gates); mutations without a gate anchor (unauditable); unpinned mutations (the unaudited-commit-reaches- main shape); a persona named in the engine (capabilities are data, the bot declares).
