PR State Flow (gate ≠ review)
How pair turns mechanical gates, a judgment review, and the risk tier into one PR state, and blocks the merge with required code-host checks instead of good intentions.
A pull request in pair has exactly three states, and nobody types them in. The state is the synthesis of signals that already exist on the pull request: the CI gate result, the review verdict, the risk:* tier, and whether a human approved explicitly.
| PR state | Meaning | Mergeable |
|---|---|---|
| to-be-reviewed | Gates not green, review not submitted (or crashed), or a 🔴 PR still waiting for explicit human approval | No |
| ready-to-merge | Gates green, review approved, and (at 🔴) a human approval on the current head | Yes: a human performs the merge, unless the project's merge gate (## Autonomy, under until: merged) lets the merge stage execute it unattended on freshly re-verified signals (same synthesis, no looser rule) |
| not-approved | The review asked for changes; the findings go back to the author | No |
Two enforcers that never blur
| Enforcer | Nature | Judgment |
|---|---|---|
| Gate: CI checks | deterministic pass/fail | zero judgment |
Review: the review process skill | a verdict on the change | zero mechanical checks: it reads gate results, never re-runs them |
The gate goes first. While any mechanical check is red the review may still report findings, but it cannot produce a merge-enabling outcome: a red gate never yields ready-to-merge, whatever the verdict says. The gate answers "does it build and pass"; the review answers "should it exist".
The review is a required check, not a convention
At PR creation publish-pr registers a pair-review check as pending, on GitHub a commit status, which an ordinary agent token can write (the Checks API is GitHub-App only) and which branch protection accepts as a required context all the same, and dispatches the review to a clean-context subagent: a reviewing context that inherits nothing from the authoring session. Because pair-review is a required status check on the protected branch, the merge is blocked from the first second:
- review approved ⇒ check
success - changes requested ⇒ check
failure - review never ran, crashed, or timed out ⇒ check stays pending, which blocks the merge exactly like a failure
So the review cannot be skipped, and a crashed review can never leave a PR quietly mergeable.
A second required check, pair-explicit-approval, enforces the 🔴 tier's extra requirement: a human, non-author approval on the current head commit (the AI review does not count, and a bot cannot substitute). It auto-passes at 🟢 and 🟡, so lower-risk PRs are never slowed down by it.
Because it is an authorization check, two implementation properties are non-negotiable: the host guide's job template has both:
- it runs from the target branch's code, never the pull request's own, otherwise a PR could rewrite the check that authorizes it and self-grant the 🔴 approval;
- its verdict is pinned to the PR head commit on every re-evaluation, including the one the approval itself triggers: a verdict recorded anywhere else would leave a 🔴 PR blocked after the human approved;
- its producer is pinned and it publishes "pending" first: a status context is writable by anyone with push access, so the protected branch pins who may satisfy it (
checks[].app_id), and the job marks the context pending before it evaluates, so a cancelled run can't leave a stalesuccessfrom a lower tier.
The two checks are not equally strong, by design. pair-explicit-approval is the authorization
control. pair-review is an anti-accident control: it is published with the reviewing agent's own
credentials, so anyone with push access could post it, what it guarantees is that a review which never
ran or crashed leaves the merge blocked, not that an independent party approved. Enforcing identity is
the other check's job.
The review is dispatched to that clean-context subagent with a bounded instruction: produce the verdict and the checks, never merge. Merging stays a human act at every tier.
A 🔴 merge needs a second human account, or the solo-maintainer token. GitHub rejects an
approving review from the PR author, so on a single-maintainer repository no 🔴 PR can satisfy pair-explicit-approval through the review path.
The alternative is an approval token: the maintainer posts /approve <head-sha> on the PR, and the job accepts it only after resolving the actor server-side (a human account, not an app, whose repository permission it reads back from the collaborators API as admin/maintain/write) and binding it to the current head, so a force-push voids it.
The author_association is only a pre-filter: MEMBER means "in the owning organization", not "has push access here". At 🟡 there is no such constraint: the pair review itself is the reviewer approval the quality model asks for.
The token is opt-in, and it is not a self-approval shortcut. Set the repository variable
PAIR_SOLO_APPROVAL_TOKEN to true (compared case-insensitively) only where the repository genuinely cannot produce a second human: that declaration is what lets the PR author's own token count.
Leave it unset (the default, and the right setting for every multi-person repository) and the token carries the same author exclusion the review path carries: a 🔴 PR can never be satisfied by its own author. Forgot to set it and posted the token anyway?
The published status says so by name (token posted by the PR author; set PAIR_SOLO_APPROVAL_TOKEN=true if this repo has one human) rather than telling you to post the token you just posted.
Read the token for exactly what it is: explicit human confirmation, not independent review. On a single-account repository there is no second pair of eyes, and pretending otherwise would make the 🔴 tier a ceremony. What the token buys is deliberateness (a distinct, explicit act), an audit trail (who, when, on which head SHA) and invalidation on change.
It is not forgery-resistant while the agent runs on the maintainer's own credentials (host-side they are the same actor) which becomes achievable only with a dedicated agent identity shipped as a GitHub App or Bot account (pair#218); a machine user account holding a PAT looks like a human to the predicate and recovers nothing.
Where a second human exists, the review path stays primary. The tier requirement itself is unchanged: this is a way to satisfy it, not a relaxation of it (pair#398).
Labels are a view; checks are the authority
The flow keeps exactly one pr-state:* label on the PR so humans and automation can filter at a glance. That label never permits a merge, branch protection evaluates the required checks and ignores labels entirely. Editing or forging a label changes nothing about mergeability; if label and checks disagree, the checks win and the next evaluation re-labels.
Tiers, fail-safe, and no criteria in the flow
Reviewer count, SLA, checklist depth, and the 🔴 explicit-approval rule come from the Quality Model, one place, overridable in adoption. The flow reads the PR's risk:* label and nothing else: no classification criteria live in it (it never inspects the diff, paths, or change size). An untagged, unknown, or malformed tier is treated as 🔴, so a missing classification always demands more: the widest gate matrix and an explicit human approval.
Things that change mid-flight
- Tier raised during review (🟡 → 🔴): requirements are re-evaluated on the new tier and the merge re-blocks until a human approves. Tiers are raise-only, so re-evaluation never loosens anything.
- Force-push after approval: checks and reviews are per head commit, so the new head has no review check: the merge blocks. The re-run is triggered, not automatic: re-invoke the review (or add a
synchronizejob that re-registers the pending check): the fail-safe direction is "blocked", so nothing slips through in the meantime. Stale human approvals are dismissed too. - Review subagent crashes: the pending check keeps the merge blocked, and the PR gets a comment with the re-run command.
- Host without a required-check API: documented degraded mode: the state is still computed and labeled, but enforcement is advisory until the branch protection is applied manually.
setup-gatesreports the gap instead of pretending the flow is enforced.
Related
- The risk-driven thread: what the PR states mean for the developer, phase by phase
- Dedicated Review Identity: who executes the flow's code-host writes, and the adoption-gated light auto-approval row
- Tag-Driven Quality Gates: the gate side of the flow (the first filter)
- Canonical States: the work-item macrostates a PR lives under (
Review) - Quality Model: the per-tier requirements this flow reads
- Quality Gates & Configuration: what is always on, and what is opt-in
Next: Review identity, who signs the review.