Goal
Make the required mergeability check actually fail on the behind condition it documents, including when GitHub's mergeable_state is reporting some other, higher-precedence word for the same pull request.
Evidence
Observed on PR #459 at head 3545c375f558265b84a5538219864930ee18e1f7 during an ACCEPTOR run on 2026-09-14:
$ gh api repos/drevendev/trade_simulation/pulls/459 --jq '{mergeable, mergeable_state}'
{"mergeable":true,"mergeable_state":"blocked"}
$ gh api repos/drevendev/trade_simulation/compare/3545c375...master --jq '{ahead_by, behind_by}'
{"ahead_by":4,"behind_by":3} # master has 4 commits the branch does not
$ gh pr checks 459 | grep mergeability
mergeability pass merges cleanly into the base branch (blocked)
The branch was behind its base by four commits (a52535e, 8a89f5d, 5a56eb1, 19e6c65) and mergeability reported success.
Cause: scripts/mergeability.py::classify() decides behind from mergeable_state == "behind". mergeable_state is a single precedence-ordered word, and dirty > draft > blocked > behind > unstable. So whenever a pull request is also blocked (a standing CHANGES_REQUESTED, an unmet required review) or also unstable (a failing non-required check), GitHub never says behind and the condition is silently invisible.
That is not a rare corner. It is exactly the population an ACCEPTOR re-reviews: a pull request carrying a standing change request is blocked by definition, so for every rework round the behind half of this check is switched off. The same masking also disables the forge's own repair — scripts/update_branches.py::should_update() deliberately skips blocked ("dirty, clean, blocked and an uncomputed answer are all left exactly as they are"), so a behind-and-blocked branch is neither gated nor updated by anything.
The documented contract says otherwise, in two places:
scripts/mergeability.py docstring / STALE_BASE comment: behind is "reported as a failure rather than left to branch protection's 'require branches to be up to date'";
docs/zendev/ACCEPTOR_RUNBOOK.md section 3: "It fails on two conditions, not one. ... A branch that is merely behind merges cleanly and is still refused."
A reviewer who follows the runbook and reads the check, as the runbook tells it to, gets a green answer to a question the check did not measure. On #459 the gap was harmless — the four commits touched docs/spec/mirror/**, docs/zendev/schemes.json and scripts/schedule_watchdog.py, none of which the pull request touches, and the ACCEPTOR closed it by merging current master locally and re-running both suites green. Two changes that were semantically incompatible would have landed unnoticed, which is the precise failure this check exists to catch.
Scope
scripts/mergeability.py: determine "behind" from a source that does not get masked — e.g. GET /repos/{repo}/compare/{head}...{base}'s behind_by, or compare/{base}...{head}'s behind_by/status — rather than from mergeable_state alone. Keep mergeable is None → pending; keep mergeable is False → failure.
scripts/tests/test_mergeability.py: extend the classify() (or its successor's) table with the masked combinations — behind + blocked, behind + unstable, behind + draft.
scripts/update_branches.py: decide whether should_update() should act on the same unmasked signal. A branch that is behind and blocked is repairable by exactly the merge this script performs; leaving it to the author is a choice, but it should be a stated one rather than a consequence of the same masking.
Non-goals
- No change to branch protection settings, and no reliance on "require branches to be up to date": the reasoning in
mergeability.py for preferring a red check over that setting still holds — the setting blocks a merge while producing no check, so the work belongs to no queue.
- No change to the
dirty/conflict path, which is unaffected.
- No change to
docs/zendev/ACCEPTOR_RUNBOOK.md. The runbook already describes the intended behaviour; this is the implementation catching up, not a policy change.
- No simulation or
src/** change.
Acceptance criteria
- A pull request that is behind its base and whose
mergeable_state is blocked receives a failure mergeability status naming the stale base.
- The same holds for
unstable, and for any other mergeable_state that outranks behind.
- A pull request that is genuinely up to date and mergeable still receives
success; an uncomputed answer still receives pending, never success.
scripts/tests/ covers all three masked combinations from criterion 2 as pure classifier cases, with no network access.
- Whatever is decided for
scripts/update_branches.py is implemented and its docstring states the decision explicitly, so a later reader does not have to re-derive it.
- Required TypeScript and retained .NET gates remain green.
Verification
python -m unittest discover -s scripts/tests, plus npm run typecheck, npm test, npm run build, dotnet build --configuration Release --no-restore and dotnet test --configuration Release --no-build.
Type: bug
Area: tooling
Priority: normal
Opened by an ACCEPTOR run reviewing PR #459, per ACCEPTOR_RUNBOOK section 1 ("a selector that picks the wrong pull request is a defect in the control plane, and the run that notices is the only thing that can report it") and section 2 ("an observed failure is what moves a gate"). Not folded into #459: it is a control-plane change and not that pull request's scope.
Goal
Make the required
mergeabilitycheck actually fail on thebehindcondition it documents, including when GitHub'smergeable_stateis reporting some other, higher-precedence word for the same pull request.Evidence
Observed on PR #459 at head
3545c375f558265b84a5538219864930ee18e1f7during an ACCEPTOR run on 2026-09-14:The branch was behind its base by four commits (
a52535e,8a89f5d,5a56eb1,19e6c65) andmergeabilityreportedsuccess.Cause:
scripts/mergeability.py::classify()decidesbehindfrommergeable_state == "behind".mergeable_stateis a single precedence-ordered word, anddirty>draft>blocked>behind>unstable. So whenever a pull request is also blocked (a standingCHANGES_REQUESTED, an unmet required review) or also unstable (a failing non-required check), GitHub never saysbehindand the condition is silently invisible.That is not a rare corner. It is exactly the population an ACCEPTOR re-reviews: a pull request carrying a standing change request is
blockedby definition, so for every rework round thebehindhalf of this check is switched off. The same masking also disables the forge's own repair —scripts/update_branches.py::should_update()deliberately skipsblocked("dirty,clean,blockedand an uncomputed answer are all left exactly as they are"), so a behind-and-blocked branch is neither gated nor updated by anything.The documented contract says otherwise, in two places:
scripts/mergeability.pydocstring /STALE_BASEcomment:behindis "reported as a failure rather than left to branch protection's 'require branches to be up to date'";docs/zendev/ACCEPTOR_RUNBOOK.mdsection 3: "It fails on two conditions, not one. ... A branch that is merely behind merges cleanly and is still refused."A reviewer who follows the runbook and reads the check, as the runbook tells it to, gets a green answer to a question the check did not measure. On #459 the gap was harmless — the four commits touched
docs/spec/mirror/**,docs/zendev/schemes.jsonandscripts/schedule_watchdog.py, none of which the pull request touches, and the ACCEPTOR closed it by merging currentmasterlocally and re-running both suites green. Two changes that were semantically incompatible would have landed unnoticed, which is the precise failure this check exists to catch.Scope
scripts/mergeability.py: determine "behind" from a source that does not get masked — e.g.GET /repos/{repo}/compare/{head}...{base}'sbehind_by, orcompare/{base}...{head}'sbehind_by/status— rather than frommergeable_statealone. Keepmergeable is None→pending; keepmergeable is False→failure.scripts/tests/test_mergeability.py: extend theclassify()(or its successor's) table with the masked combinations — behind +blocked, behind +unstable, behind +draft.scripts/update_branches.py: decide whethershould_update()should act on the same unmasked signal. A branch that is behind and blocked is repairable by exactly the merge this script performs; leaving it to the author is a choice, but it should be a stated one rather than a consequence of the same masking.Non-goals
mergeability.pyfor preferring a red check over that setting still holds — the setting blocks a merge while producing no check, so the work belongs to no queue.dirty/conflict path, which is unaffected.docs/zendev/ACCEPTOR_RUNBOOK.md. The runbook already describes the intended behaviour; this is the implementation catching up, not a policy change.src/**change.Acceptance criteria
mergeable_stateisblockedreceives afailuremergeabilitystatus naming the stale base.unstable, and for any othermergeable_statethat outranksbehind.success; an uncomputed answer still receivespending, neversuccess.scripts/tests/covers all three masked combinations from criterion 2 as pure classifier cases, with no network access.scripts/update_branches.pyis implemented and its docstring states the decision explicitly, so a later reader does not have to re-derive it.Verification
python -m unittest discover -s scripts/tests, plusnpm run typecheck,npm test,npm run build,dotnet build --configuration Release --no-restoreanddotnet test --configuration Release --no-build.Type: bug
Area: tooling
Priority: normal
Opened by an ACCEPTOR run reviewing PR #459, per ACCEPTOR_RUNBOOK section 1 ("a selector that picks the wrong pull request is a defect in the control plane, and the run that notices is the only thing that can report it") and section 2 ("an observed failure is what moves a gate"). Not folded into #459: it is a control-plane change and not that pull request's scope.