A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live
docs/INTEGRATION.md
Pull-request integration
What it is
- URI
majordomus://document/docs/INTEGRATION.md- Kind
document- Identity
docs/INTEGRATION.md- Path
docs/INTEGRATION.md- Provenance
- declared
- Media type
text/markdown- As a capability
- document.docs/INTEGRATION.md
Front matter
{}Content
# Pull-request integration
Majordomus does not merge a list of pull requests. It integrates the next provably safe
change into the current master, verifies that it landed, discards what it assumed, and
decides again from what is there now. The decision is recorded in ADR 0101, the rule is
`project.integration-follows-the-current-master`, and the code is `crate::integration`.
## The model in force
That is the executor. Who lands, how much at once and how often is decided by the owner, and
the decision of 2026-10-16 is
[rule `project.the-cooperation-model-is-the-owners-word`](../.ai/repo/rules/project/the-cooperation-model-is-the-owners-word.v1.md)
and [ADR 0167](../.ai/repo/adrs/0167-work-moves-through-seats-on-the-mesh-and-lands-in-combined-tops.md).
The rule is the one record of the seats, the scope each one claims on the mesh and what each
does, and this document does not copy it: the site renders the rule and the decision from
those files, so they cannot drift from what is read here. Who holds each seat now is drawn
by the Mesh screen of the UI (`/ui/mesh`) from the live claims.
What the decision changes here is the lander's unit of work, not the executor. `prs drain`
below still takes one ready pull request at a time and never passes `--admin`; a combined top
is the landing seat's own act, the batch ADR 0101 left to a person and the owner has now
chosen. Everything else this document says of a decision still holds for a top: it is taken
against the current master and the heads it names, its evidence is recorded, and no check is
bypassed.
## The pipeline
```mermaid
flowchart TD
gh["forge (gh)"] --> obs["observation<br/>(recorded, with its moment)"]
git["git merge-tree + check-attr"] --> rel["relation to master<br/>(cached by SHA pair)"]
obs --> cls["classify: one assessment per pull request<br/>disposition, reasons, evidence, risk"]
rel --> cls
cls --> rank["rank: deterministic order,<br/>next merge, next refresh"]
rank --> surfaces["CLI · HTTP · MCP · Cockpit<br/>(offline)"]
rank --> drain["drain: lease, observe, decide,<br/>observe again, act, verify"]
drain -->|every merge: plan void| gh
```
The forge's `mergeable` flag is never read. This repository resolves derived files with a
per-clone merge driver (`merge=derived`) that the forge cannot run, so the forge calls nearly
every pull request conflicting. The relation to master is decided by git: `git merge-tree
--write-tree` with the drivers, and `git check-attr merge` for which paths are derived,
according to master's own `.gitattributes`.
The relation is decided on exactly the head the forge reported, the one the assessment names
as evaluated. A head that moved during the refresh is not in the clone, and its relation is
`unknown` until the next refresh. It is never decided on whatever a fetched ref holds now.
When git cannot say which paths are derived, because `git check-attr` failed, the relation is
`unknown` too, never "nothing is derived". A relation is cached only under a pair of full
commit ids, and an `unknown` is never cached.
Where the merge would conflict, or would change only derived output, `git cherry` is asked as
well. When every commit the head has and master lacks is on master already as an equal patch,
the relation is `patch_ids_upstream`: the change landed, by a cherry-pick say, and master moved
past it. A partial match never gives it. Neither does a head with a merge commit of its own in
that range, because a merge's resolution has no patch to compare, nor a clean merge that
changes authored paths: there master lacks something the head carries, such as a landed change
that master has reverted since.
A head that does not contain master is not always behind it. The relation is `carries_master`
when two things hold: its merge into master is clean and writes the head's own tree, and every
commit master has and the head lacks is a merge commit. Master then holds nothing the head
lacks. That is a pull request built on another one's head, once the other has landed (see *A
train* below). No path is merged in such a merge, so no driver decides one and the forge's
merge writes the same tree. The second half is there because derived output is composed from
history as well as from files: the changelog lists every commit that is not a merge, so a
master that took a change and reverted it has the tree it had and two commits no changelog on
the head ever listed. One change on master that the head never saw, authored or derived, or
one commit of master's own that is not a merge, and the head is `behind`.
## Dispositions
Every open pull request has exactly one. They are decided in the order below, so an earlier
answer wins. `ready` is reached only after every other question is answered in its favour.
| Disposition | Lane | When | Next |
|---|---|---|---|
| `other_base` | held | targets a branch other than the base, and no open pull request's head | — |
| `waiting_for_dependency` | waiting | stacked on another open pull request of this repository, or declares a dependency on an open one (see below) | land that one first |
| `draft` | held | a draft | mark it ready |
| `obsolete` | cleanup | carries a label that marks it obsolete (owner decision D3; see the label policy below); checked before the hold labels, after `draft` | a person closes it, or removes the label |
| `blocked` | held | carries a label that holds it (see the label policy below) | remove it |
| `unsafe` | held | the forge has auto-merge armed on it, so the forge would merge it on its own | disarm it: `gh pr merge <n> --disable-auto` |
| `unknown` | held | the pull requests that mention it were not all read (`declarations_unread`), so one of them may declare that it supersedes it; or an open one that says it supersedes it is not among the open pull requests the forge listed (the list was cut at its limit, or it was opened between the two reads) | `prs refresh` |
| `superseded` | cleanup | an authorised declared successor landed: git finds its head or its merge commit in master (see supersession markers below); `superseded_by` names it | `prs cleanup --apply` closes it |
| `waiting_for_dependency` | waiting | an authorised declared successor is still open | land the successor; this one is then closed, never merged |
| `possibly_redundant` | cleanup | a declared successor is merged, but master contains neither its head nor its merge commit | a person decides |
| `unknown` | held | a declared successor is not open and could not be read, or was read without where its head lives or how many files it changes | `prs refresh` |
| `redundant` | cleanup | its head is an ancestor of master, merging it changes no file, or every one of its commits is on master as an equal patch | `prs cleanup --apply` closes it |
| `possibly_redundant` | cleanup | merging it changes only derived artifacts | a person decides |
| `unknown` | held | its head is not fetched, git failed, the branch protection could not be read or binds a check to an app id that is not an integer, or a check run of an app-bound context names no app | `prs refresh` |
| `conflicting` | repair | the merge conflicts on an authored path | the author resolves it |
| `blocked` | held | the repository's settings allow no merge commit (see the merge method below) | allow merge commits |
| `waiting_for_review` | waiting | a required review is missing or changes were requested (see below) | a reviewer |
| `needs_repair` | repair | a required check failed on its head, or it is behind master from a fork | the author |
| `needs_refresh` | waiting | merges cleanly, and neither contains master nor carries it | `prs drain --refresh`, or `prs repair <n> --apply` for one |
| `waiting_for_checks` | waiting | contains or carries master; a required check is pending or missing on this head | wait |
| `ready` | ready | contains or carries master, and every required check passed on this head | `prs drain` |
A required check that is pending, missing, skipped or unreadable is not passed. The required
checks are read from the base's branch protection and rulesets, each with the app bound to
it, never listed here. A green check that is not required proves nothing.
A check bound to an app is only that app's check run. A commit status of the same name is
not it, and neither is another app's check run of that name: it cannot pass the check, fail
it or hold it pending. A context two sources bind to two apps is required of both. `gh pr
list` does not say which app wrote a check run, so when a context is bound the refresh asks
the forge who wrote each one: one GraphQL read (`gh api graphql`, a query, never a mutation)
for every 50 open pull requests, and one more for each page of a head that carries more
than 100 checks. A base that binds nothing asks nothing more. A writer read that fails
fails the refresh, as a failed list does.
A check run of a bound context whose app was not read is never taken for the bound app's:
the check is `unknown` (`required_checks:unknown`), never passed, and the queue says which
pull requests carry one. This happens when the head moved between the list and the writer
read, which the next refresh clears, and when a check suite names no app or a head carries
more than 1000 checks, which no refresh clears: a person looks. An observation recorded
before writers were read has none, so every bound check reads `unknown` until one `prs
refresh`.
The binding names the app, not the workflow. Every workflow of a repository writes as the
same app (GitHub Actions is one app), a workflow a pull request's own head defines or
rewrites included: a head that changes `.github/workflows/` decides what its own check
named `ci` runs. The binding keeps another app out. It does not prove which workflow ran.
### Gates, reasons and evidence
The order above is a list of gates, and every gate is asked whatever the others answer:
| Gate | Passes when | A failure's reasons |
|---|---|---|
| `base` | it targets the base | `stacked_on:#N`, or `base_is:BRANCH` |
| `draft` | it is not a draft | `draft` |
| `label` | no label that holds it | `label:NAME`, one per label |
| `auto_merge` | the forge has no auto-merge armed on it | `auto_merge_armed` |
| `supersession` | its cross-references were read whole, and no authorised successor is open, landed, merged elsewhere or unread | `declarations_unread`, then `superseded_by:#N`, `successor_open:#N`, `successor_not_landed:#N`, `successor_unread:#N`, one per successor |
| `relation_to_master` | its merge is clean and changes something | `head_reachable_from_master`, `merge_changes_nothing`, `patch_ids_upstream`, `only_derived_artifacts_differ`, `relation_unknown:WHY`, `conflicts_on:COUNT` |
| `merge_method` | the repository allows a merge commit | `merge_commit_not_allowed` |
| `dependency` | every declared dependency landed | `depends_on:#N`, one per open dependency |
| `review` | the review policy is satisfied on the head | `review:STATE`, or `review_policy_unread` |
| `no_failing_check` | no required check failed | `required_check_failed` |
| `freshness` | the head contains master, or carries it: merging it writes the head's own tree and it lacks only merge commits | `behind_master:COMMITS`, with `fork_head` for a fork |
| `required_checks` | every required check passed | `required_checks:STATE` (`pending`, `missing`, or `unknown` when a check run of an app-bound context names no app), `no_required_checks`, `required_checks_unread` |
The assessment's `gates` lists every gate with whether it passed. Its `reasons` hold every
failing gate's reasons in this order, and the first is the decisive one: the disposition is
the first failing gate's. A draft that also conflicts and fails its check is `draft`, with
`draft`, `conflicts_on:1` and `required_check_failed`. A gate that fails only because an
earlier one did, such as `freshness` when the merge conflicts, adds no reason of its own. A
ready pull request has `contains_master` with `required_checks_passed` or
`required_checks_skipped`. The reasons are typed (`ReasonCode`), but on the wire they are the
same strings as before. A code that an older trail line carries and this list does not name
is kept verbatim.
Each piece of evidence has a `kind`, a `status`, a `detail` and a `source`. The source is the
forge observation at its moment, or git on the named master and head. Evidence for `base` and
`draft` is always there. So is `required_checks`, with one `required_check` per context the
base requires, and `review` and `relation_to_master`. `dependency` appears per declared
dependency, `label` per label that holds it, `auto_merge` whenever the forge has auto-merge
armed, `repository_settings` when the settings allow no merge commit, and `supersession` per
authorised declared successor (`open`, `landed`, `not_landed` or `unread`; `landed` is git's,
on master and the successor's head or merge commit), per declaration nobody entitled made
(`possible_supersession`, naming who made it), and once when the pull request's
cross-references were not read whole (`references_truncated` or `references_unread`).
`freshness` is a reserved kind. `evaluated_against` names the master, the head and the moment of the
observation; two decisions are the same when the master and head are.
### Label policy
The labels that have an effect are one table, `LABEL_POLICY` in
`src/integration/classify.rs`. Each label has an effect; the policy in force copies the table
(`policy.labels` in `prs status --json` and `integration.queue`) and the classifier reads that
copy. No other list of labels exists. A label is compared case-insensitively.
| Label | Effect |
|---|---|
| `do-not-merge` | hold |
| `do not merge` | hold |
| `blocked` | hold |
| `hold` | hold |
| `on-hold` | hold |
| `wip` | hold |
| `manual-merge` | hold |
| `obsolete` | obsolete |
`hold` makes the pull request `blocked`, with `label:NAME`. `obsolete` makes it `obsolete`,
with `label_obsolete:NAME` in the forge's spelling (owner decision D3): a person's mark is the
only evidence that reaches the word — age, shared paths and a similar title never do — and it
is never closed automatically. A pull request carrying both is `obsolete`, since a person acts
on it either way; a draft carrying it stays `draft`, because the draft gate is asked first.
There is no label that opts a pull request out of the executor's refresh (owner decision D11).
### Auto-merge and the merge method
A pull request on which the forge has auto-merge armed is `unsafe`. The forge would merge it
on its own once its own conditions hold, outside the executor and against whatever master is
then, and refreshing it would only hand the forge a head to merge. Nothing acts on it until a
person disarms it, and the queue lists every such pull request in a diagnostic.
The executor merges only with a merge commit, because the derived-file driver resolves merges
and a squash or a rebase would replay commits it never saw. The merge method
(`policy.merge_method`) is `merge` when the repository's settings allow a merge commit
(`allow_merge_commit`, read with `gh api repos/OWNER/NAME`), and none otherwise. With none,
every pull request the relation to master does not already decide is `blocked` with
`merge_commit_not_allowed` and `repository_settings` evidence, and the queue says why. The
executor never falls back to a squash or a rebase. The `merge_method` gate comes after
`relation_to_master` because closing work that is already on master does not depend on the
merge setting: a `redundant` pull request stays `redundant`, lists
`merge_commit_not_allowed` after its own reason, and `prs cleanup --apply` still closes it. So
does a `superseded` one.
### Review states
The forge's review decision comes first, and the branch protection's requirement second:
| The forge says | The protection requires a review | Review | Disposition, if nothing earlier decided |
|---|---|---|---|
| `CHANGES_REQUESTED` | any | `changes_requested` | `waiting_for_review` |
| `APPROVED` | any | `approved` | goes on to the checks |
| `REVIEW_REQUIRED` | any | `pending` | `waiting_for_review` |
| nothing | yes | `pending` | `waiting_for_review` |
| nothing | no | `not_required` | goes on to the checks |
| nothing | unread | `unknown` | `unknown` |
`REVIEW_REQUIRED` is pending even when the branch protection requires no review. A ruleset or
code owners can require one that the protection does not, and the forge's word is that a
review is still owed.
### Dependency markers
A pull request depends on another when a line of its body opens with one of these markers,
in any case, followed by one or more numbers:
- `Depends on #N`
- `Stacked on #N`
- `Requires #N`
- `Land after #N`
Only a bullet (`-`, `*`, `+`, `1.`), quote marks (`>`) and emphasis (`*`, `_`) may come before
the marker, so `- **Depends on:** #7` declares a dependency. More numbers follow with commas,
`and` or `&`: `Stacked on #644 and #645`. The same words anywhere else in a line are prose:
`a regression introduced after #540` and `thereafter #5` declare nothing, and neither does a
bare `After #N`.
A dependency is satisfied only when that pull request **merged**. The refresh reads every
declared dependency that is not open (`gh pr view`, with the successors), so the queue knows
what became of it:
| The dependency | The pull request | Reason |
|---|---|---|
| open | `waiting_for_dependency` | `depends_on:#N` |
| merged | not held by it | — |
| closed without a merge | `blocked`: its work never landed, so a person reopens it or removes the declaration | `dependency_closed_unmerged:#N` |
| not open, and the forge could not say — refused, or not a pull request at all | `unknown`, never satisfied | `dependency_unread:#N` |
| part of a cycle of declared dependencies between open pull requests | `blocked`: none of them can land first | `dependency_cycle:#N`, one per other member |
A cycle is said before anything else about the dependencies, then a closed one, then an
unread one, then an open one. Git also implies dependencies: a pull request whose observed
head contains another's observed head carries its commits, so landing it lands both. Each
such pair is an `inferred` dependency with `inferred` evidence — never a block, never a
change of disposition or rank; only a declaration holds a pull request back. The refresh lists
at most 500 open pull requests; a forge with as many says so in the queue's diagnostics,
because a dependency on one beyond them would read as unread.
### Supersession markers
A pull request is replaced by another when a line of a body declares it, read by the same
line-anchored parser as a dependency:
- `Superseded by #N` in its own body names N as its successor;
- `Supersedes #M` in N's body names N as the successor of M, from the other side.
**Who may declare.** A declaration counts only when the pull request whose body carries it
is a branch of this repository, not a fork, and the forge calls its author an `OWNER`,
`MEMBER` or `COLLABORATOR` of the repository. That holds in both directions and is tested on
the author of the body that speaks: `Superseded by #N` on the replaced pull request's own
author, `Supersedes #M` on the successor's. Any other association (`CONTRIBUTOR`,
`FIRST_TIME_CONTRIBUTOR`, `NONE`), a fork, or an association that was not read is not
authorised. A pull request a bot opened carries whatever association the forge gives the
bot, which is authorised only if it is one of the three. A declaration that is not authorised
changes no disposition: it neither holds nor closes, and it is reported as `supersession`
evidence with the status `possible_supersession`, naming who declared it, their association,
whether the branch is a fork's, and the remedy: an owner, member or collaborator says
`Supersedes #M` in a pull request of this repository.
Only what the body's author states is a declaration. A marker on a quoted line (one that
opens with `>`), inside a fenced code block (three backticks or three tildes) or inside an
HTML comment is someone else's words, an example or a template's hint, and declares
nothing. A body pasted or prefilled from a commit message is still its author's: what they
submit under their name is what they state. A dependency keeps the lenient reading, quoted
or not, because it can only make a pull request wait.
Two limits of this test are known and accepted (ADR 0101 §6, residuals). `MEMBER` is the
forge's word for any member of the organisation, whatever their access to this repository,
and it is read when the observation is made: a former collaborator who is still a member
can edit the body of an old pull request of theirs here and have it count. A personal
repository has no members. And an open pull request whose repository flag the declarations
read did not state is treated as a fork's: it declares nothing.
The `supersession` gate is asked before `relation_to_master`, because a pull request whose
successor landed usually conflicts with what the successor brought, and it is superseded, not
conflicting. For an authorised declaration, what became of the successor decides:
| The successor | Disposition | Reason | Evidence | Closed by cleanup |
|---|---|---|---|---|
| is still open | `waiting_for_dependency` | `successor_open:#N` | `open` | no |
| landed: the forge calls it merged, and master contains its head or its merge commit | `superseded`, with `superseded_by: N` | `superseded_by:#N` | `landed` ("is merged") | yes |
| landed inside a batch: the forge calls it closed, its head lives in this repository, and master contains that head | `superseded`, with `superseded_by: N` | `superseded_by:#N` | `landed` ("is closed") | yes |
| was closed unmerged and its head lives in a fork, wherever that head points | its own relation's: the hold is released | none from this gate | `not_landed` | only if its own relation makes it `redundant` |
| changes no file, as the forge reports it, wherever its head points | closed: its own relation's; merged: `possibly_redundant` | none, or `successor_not_landed:#N` | `not_landed` | no |
| was closed unmerged, and master contains neither its head nor a merge commit of it | its own relation's: the hold is released | none from this gate | `not_landed` | only if its own relation makes it `redundant` |
| is merged, but master contains neither its head nor its merge commit (merged into another branch, or this clone's master is behind) | `possibly_redundant` | `successor_not_landed:#N` | `not_landed` | no: a person decides |
| is not open and could not be read | `unknown` | `successor_unread:#N` | `unread` | no |
| any, when the replaced one's cross-references were not read whole | `unknown`, whatever else is known | `declarations_unread` | `references_truncated` or `references_unread` | no |
| any, declared by anyone else or from a fork | no change: its own | none | `possible_supersession` | no |
Landed is git's fact. The successor's head, or the merge commit the forge names for it, is an
ancestor of master; the forge's `merged` only words the evidence ("is merged" or "is closed").
So a successor merged by a squash lands by its merge commit, and one closed after a batch
carried its head into master landed too.
Git's fact is trusted only about a head nobody could move to manufacture it (ADR 0101 §6,
the amendment to D3; the owner may reverse it). A successor the forge does not call merged
lands only when its head lives in this repository: a fork's author can force-push their
branch to any old commit of master and close the pull request, and that head would then be
an ancestor of master with none of the work there. So a cross-repository successor closed
unmerged never lands and releases the hold, like any successor closed without landing. One
the forge calls merged lands by its head or its merge commit, fork or not. A successor the
forge says changes no file brought nothing, and never lands either: that is the same trick
played with a branch of this repository reset onto master.
A partial read of the cross-references decides before anything else. Then, of several
authorised successors, one that landed decides, then one still open, then one merged
elsewhere, then one unread; one closed unmerged holds nothing and is only evidence. When both
bodies declare the same pair, the replaced one's own body is the declaration if its author
may declare, and the successor's otherwise. On the wire the disposition stays one word, and
`superseded_by` (present only with `superseded`) is its successor.
**Where declarations are read.** A successor that is no longer open is not among the open
pull requests, so the refresh asks the forge, with every open pull request, for its author's
association and for the pull requests that mention it: its cross-references (one GraphQL
read, `gh api graphql`, a query and never a mutation, for every 50 open pull requests). A
mention is a declaration only if that pull request's body says `Supersedes` and names it; a
mention from another repository is dropped, because a `#N` there is not this repository's.
There is no search of closed pull requests. Each successor or dependency an open body names
that is not open is read with `gh pr view`, and every such head is fetched so that git can
say whether it landed. With no pull request open, nothing is asked.
One page holds a hundred cross-references. A pull request with more is read again on its
own, up to fifty pages, five thousand references. Beyond that, or when the forge says more
follow and names no cursor, the read is truncated and the pull request is held: `unknown`,
`declarations_unread`, evidence `references_truncated`, never merged and never closed, and
the queue's diagnostics name it. The hold is that pull request's alone: the rest of the
queue is decided and keeps moving. A pull request the forge left out of the answer is held
the same way (`references_unread`), and the next refresh reads it again. Truncation never
releases a hold and never closes.
**Held by its mentions.** The limit counts every cross-reference, and a mention by an issue
or from another repository is dropped only after it was paged through. So anyone who can
write `owner/repo#N` more than five thousand times, anywhere, holds #N: every refresh reads
fifty pages and finds it truncated, and no command releases it. This is a known residual
(ADR 0101 §6, R2): it blocks one pull request and never closes one, and a person decides
it. No release marker exists.
A declarations read that fails is a different outcome. When the forge will not answer a
page, or one pull request's references (a refusal, or an outage that outlasts the retries),
the whole refresh fails with exit 12, as a failed list does, and records no observation:
the one recorded before stays byte for byte what it was, and nothing is decided from a
part of an answer.
A pull request that targets another branch is stacked on the open pull request whose head is
that branch. Only branches of this repository count. A fork's branch says nothing about a
branch here, whatever it is called, so a fork whose branch is named `master` stacks nothing.
### Issue and milestone
Each assessment names the issue its head branch names, when it names one, and that issue's
milestone: `issue` and `milestone`, absent otherwise. The branch is the link, in the form
`majordomus worktree create --issue <id>` gives it (`feature/<id>-<slug>`): a path component
equal to an issue id of `.ai/repo/project/issues/`, or the id followed by `-`, and nothing
else counts — a branch `feature/I10030` does not name `I1003`. The milestone is the one the
issue's record declares. Both are derived on every read of the queue from the recorded
observation and this checkout's project model, never stored beside them; neither decides a
disposition or a rank. `prs explain` prints them (`issue: I0810 · milestone M003`),
the Cockpit shows them beside the title, and `prs status --format json`, the HTTP API and MCP
carry them on each assessment.
## What a change touches
Beside each head's relation, from the same pair of commits and cached under the same key in
`relations.json`, the queue reads what its merge changes by kind (`change_shape` on the
assessment): its authored paths, its derived paths, and `version_bump`, the version it
declares in `apps/majordomus-cli/Cargo.toml` when that is not the one its merge base declares.
The authored paths are the whole change, a conflicting head's included, and they decide:
| What two open pull requests share | `overlaps[].kind` | Risk factor |
| --- | --- | --- |
| both raise the crate's version | `version_bump` | `overlapping_version_bump:#N` |
| both change a record under `.ai/repo/releases/` | `release` | `overlapping_release:#N` |
| an authored path | `authored` | none |
Risk is low, medium or high from the paths touched. A head that raises the version
(`version_bump:V`), a shared version bump or release, and a relation git could not decide
(`paths_unknown`) are high: whichever of two version bumps lands second has to be
re-derived on the first, and a change nobody could read is never "documentation only".
## The rank
The queue is ordered by lane, disposition, risk (low, medium, high, as above), how many
other ready or refreshable pull requests it overlaps (fewer first, because landing it
invalidates less), how many open pull requests declare that they wait for
it and are not yet satisfied (more first, because landing it unblocks them), how many
authored paths it changes (fewer first: a smaller change is cheaper to land and to undo), age
(older first, so new easy work cannot starve old work) and number. Every key is a value of
the assessment or of the queue around it, so the order is total and does not depend on the
order the forge listed them; `src/integration/tests.rs` proves this as a property. Each
assessment carries the factors it was ranked by (`rank_factors`), and `prs explain` prints
them, so a rank is never a number without its reasons.
## Waiting and starvation
Every `ready` or `needs_refresh` pull request carries how long it has been the executor's to
act on, and how often the executor chose another instead (`wait` on the assessment). Both
are folded from the audit trail (`crate::integration::wait`) and from nothing else:
- each executor step records the transitions since the trail's last word, as
`became_actionable` and `left_actionable` (with the disposition it has now, or that it is no
longer open);
- each selection records the other actionable pull requests it `passed_over`.
A pull request passed over three times in its current wait is listed as `starving`. That
shows on `prs status`, `prs explain`, the Cockpit and the briefing, and it never changes the
rank. The age tie-break already prefers the older of two otherwise equal candidates, and a
long wait is never a reason to merge something less safe sooner. A dry run records neither
the transitions nor the selection, so a dry run still leaves no trace. A wait starts when the
executor first sees the pull request as actionable, not when the forge was first observed.
## One merge at a time
`prs drain` loops over one step and holds nothing between steps:
1. observe the forge, fetch every open head, and build the queue;
2. take the first `ready` pull request;
3. observe again and rebuild the queue;
4. act only if the second decision names the same master and head and still says `ready`,
and otherwise record `stale_decision` and start again;
5. merge with a merge commit (`--merge`) and `--match-head-commit <head>`, never `--admin`,
a squash or a rebase;
6. verify that the forge shows it merged and that the fetched master contains its head;
7. record the merge with the master before and after.
A merge moves every other pull request behind master, so the next step always starts from a
new observation.
A merge that changes a projected plan record leaves GitHub behind by exactly that change.
The records are the files under `.ai/repo/project/issues/` and `.ai/repo/project/milestones/`
and every record whose derived status moves with one of them. `scripts/github-sync --apply`
projects master and nothing else, and `prs drain` never runs it. The landing does: every push
to master starts `.github/workflows/github.yml`, which asks what GitHub does not show yet
(`scripts/github-sync --check`), applies it, and proves the result with
`scripts/ci/github-check`. The workflow holds `issues: write` and nothing broader, runs one
at a time, is never cancelled, and refuses to apply without the Rust executable. Its model is
`.ai/repo/ci/github.yaml` and the rule is `project.the-projection-is-applied-at-landing`.
Whoever operates the executor has no step to remember here.
`scripts/ci/github-check` gives the apply a declared window, `apply.window` in the same
file. On the pull request's own head the records it changed are reported `PENDING` and not
refused. On master they stay `PENDING` while the landing is younger than the window. Past it
the gate refuses master and names the command. That is the fallback, for a run that failed: a
person runs `scripts/github-sync --apply` from the clean tip of master, with `MAJORDOMUS_BIN`
naming the executable. Until 2026-10-12 that manual apply was the mechanism, and master went
red on the day the executor was busy for longer than the window.
When nothing is ready, `prs drain --refresh` brings master into the first `needs_refresh`
pull request. It uses a scratch worktree under the common git directory, runs `git merge
--no-commit` with the derived driver (rerere off, so no remembered resolution decides a path
nobody looked at), runs `scripts/derive`, commits with the hooks running, and pushes a
fast-forward to the pull request's branch with a plain push, never a forced one. It first
checks that what it pushes descends from the head it observed, and that origin still serves
that head (`git ls-remote`): a branch its author moved since, forward, sideways or back, is
refused and nothing is pushed. Between that check and the push, git itself refuses a branch
moved forward or sideways, since the push is then no fast-forward. A rewind in that window,
the author force-pushing the branch back to an ancestor of the observed head, is the one move
that slips through: the push is then a fast-forward that restores the commits the author
removed. That window is accepted; no history is ever overwritten. That is a merge commit and
never a rewrite, as `project.land-and-publish` prescribes. The pipeline is one deep: while a
refreshed pull request waits for its checks, no other is refreshed, because merging the first
would put the second behind again. Throughput is therefore one pull request per run of the
required check, which is the true cost of this repository's mechanics.
Only a run the executor started holds the pipeline: the required check of the head a
`refreshed` event recorded as pushed (`head_after`), while it is *pending* or *missing*. Both
count, because the forge creates no check run for an aggregate job such as this repository's
`ci` until every job it needs has finished, so the executor's own head reads as `missing` for
most of its run. A `missing` check holds the pipeline only for `REFRESHED_HEAD_REPORTS_WITHIN`
(four hours) after that event. Past it, the check is taken never to report and the next pull
request is refreshed, so one silent check cannot stop every refresh. A check running on a head
the author pushed holds nothing. A `refreshed` event recorded before `head_after` existed names
no head, so a pull request refreshed by an older executor does not hold the pipeline.
### A train
One pull request per run of the required check is the cost of refreshing each head onto
master alone. A train pays it once for several (ADR 0129). Its links are pull requests whose
heads are stacked: the second is brought up to date on the first one's refreshed head, not on
master, the third on the second's, and each says so with a `Stacked on #N` line in its body.
Every link's required checks run at the same time, each on a head that holds everything below
it.
| The link | Is |
|---|---|
| the lowest, with its checks passed | `ready`, `contains_master` |
| above an open link | `waiting_for_dependency`, `depends_on:#N`, whatever its checks say |
| above a link that landed, with its checks passed | `ready`, `carries_master`: no refresh, and no second run of the checks |
| above a link that landed after its head moved | `needs_refresh`: master holds what this head never saw |
| above a link that was closed without a merge | `blocked`, `dependency_closed_unmerged:#N`: a person decides |
The order is the dependency gate's and not a habit: a link merges only after the one it names.
What makes the second merge safe is the relation, decided by git and never assumed from the
stacking: the merge of the link into the master that now holds the first must write the link's
own tree, and the first one's merge commit must be all the link lacks. Then the verdict its
head already has is a verdict on exactly what master becomes.
A link that goes red costs the links above it one ordinary refresh after it is fixed and
lands, which is what every one of them costs without a train, and never a merge of a tree
nobody judged.
A train is prepared by whoever brings the heads up to date; the executor decides each link on
what git says of it and does not build one itself yet.
### The record pull request it reopens
A release proposes its record as `release/record-<tag>`, opened with the workflow's own
token, and a forge starts no workflow from an event that token caused. Such a pull request
has no check of its own and never will: it reads `waiting_for_checks` with
`required_checks:missing` for as long as nobody closes and reopens it, and until the record
is on master the release check fails on every other open head.
So when nothing is ready, and before any refresh, `prs drain` looks for a pull request from
a `release/record-` branch in that state and asks the forge what the queue does not carry:
who opened it, when, and whether it has a single check of its own. If it is open at the head
decided on, was opened by `app/github-actions`, is older than two minutes
(`RECORD_REOPEN_GRACE`) and its rollup is empty, the executor closes it with a comment
saying why and reopens it at once, which is the event that starts its run. The act is taken
once per head: a head the trail names in `reopen_attempted` or `reopened` is never tried
again, because a reopened pull request also reads `missing` until its run's last job exists.
It pushes nothing, so it needs no `--refresh`, and `--dry-run` only names it.
A record a person opened, one that has any check of its own, one still inside the grace,
one the forge cannot show and every branch that is no record are left alone. If the forge
refuses the reopen after the closure, the pull request is closed: the drain says so, names
`gh pr reopen <n>`, records `reopen_failed` and exits 10.
## An expedited landing
Everything above is the executor, and the executor never passes `--admin`. One thing is
outside it: the owner of the branch protection may decide to spend it. Rule
`project.an-expedited-landing-is-the-owners-word` and ADR 0155 say when a named lander may
merge past the required check, and what that never reaches.
Since 2026-10-16 the lander is the landing seat of
[*The model in force*](#the-model-in-force), and what it lands may be a combined top rather
than one pull request: one seat at a time, and a landing may carry a whole top.
The required check takes about an hour and the pool judges one pull request at a time. On
2026-10-12 a release record that changed one file waited two and a half hours behind runs
nobody was going to read, while the site named a release two versions old. The owner's order
that followed is quoted in the ADR. What makes a landing under it safe was measured over the
next day:
| the lander | because |
|---|---|
| holds the exclusive landing claim on the mesh, alone | a merge between another lander's gates and its merge makes those gates a verdict on a tree that no longer exists |
| merges master into the branch, commits the merge, then derives | a head judged alone is not the tree that lands, and a derive taken on an uncommitted merge is stale |
| runs the whole structure job, the library tests and the cases the change touches on that merged tree, and names the commit each ran on | a gate nobody ran reads as a gate that passed; a landing with the structure job unfinished put two reds on master |
| before judging, searches the tests for what reads the change: a sibling's identifier across `apps/majordomus-cli/tests` and `test/cases` when the change adds a named record (a skill, a screen, a rule, a capability), the script's name when it changes how a script calls a tool; and runs every test found | a test that lists records by name, or that replaces a tool with a stand-in and reads what it was asked, lives in the rust lanes and the suite shards, which the structure job does not run; two landings of 2026-10-13 passed that job and left the trunk red there, for 95 minutes and for the length of the next run |
| after a red is found, searches for the same shape before pushing its fix | the first fix of 2026-10-13 named the skill in the crate test; the case that makes the same demand would have been the next red, one run later |
| merges with `gh pr merge --admin --merge --match-head-commit <head>` | a head that moved after it was judged is refused |
| cancels its own branch's run after the merge | a run nobody will read still holds a runner, and the trunk's run waits behind it |
| verifies the commit production serves, with `scripts/pages verify` | every landing is a deployment, and a merge that broke the publication says nothing |
A run is stale, and is cancelled, when its pull request is merged or closed, when its head is
no longer the head of its branch, or when another run of the same workflow already judges
the same commit. The trunk's only run of a commit, a deployment in flight, a release run and
another session's run on a current head are never cancelled. A landing on the trunk cancels
nothing by itself: the trunk's validation runs are each their own concurrency group, and the
publication queue never cancels the deploy in flight.
A run that can no longer end green is cancelled as well: once one job of a branch's run has
failed, the jobs still running decide nothing about that head, and they hold the runners the
trunk's run waits for. The lander reads the failed job first, through the jobs API, which
answers while the run is still going. The trunk's own run is left to finish: its other jobs
are the only reading of the trunk's other lanes. While the trunk's run is queued, a branch
run on a head that contains the trunk is that reading, lane by lane, and is read before it
is cancelled. On a pull request the workflow does this by itself: the last step of every gate
job, on `failure()` of a `pull_request` event alone, cancels the run by its own id
(`.github/workflows/validate.yml`, case 3019). A push to master, the schedule and a dispatch
are never cancelled that way.
What the merged tree owes follows what the change touches, and rule clause 4 lists it: a
record a crate test reads (a skill, a rule, a claim, the quality baseline, a plan record) owes
the crate tests that open this repository itself (`tests/skills.rs`, `tests/quality.rs` and
their siblings) and the `intent-check` gate, although no Rust moved. An active skill missing
from the header of `tests/skills.rs` or from case 391 is refused in the structure job by the
`skill-naming` gate, and a case that names the release record without being run by the
`release-record-cases` gate by the `release-record-listing` gate. A change that adds or
widens an exported Rust item owes `scripts/rust-check --quality` before it is pushed: the
`rust-quality` gate's report, on the author's machine, with the finding the structure job
would print; two implementations of 2026-10-14 met that gate only in the structure job.
A red on the trunk has one carrier, named by the integrator. A second session that builds the
same fix costs a disk and lands nothing sooner.
Between a tag and its record nothing else lands. The record pull request the release
workflow opens is the record and the files derived from it, generated against the master of
that minute; a landing in that window moves those files under it, and the site keeps naming
the previous release until the record has been merged with master and derived again. The
record is merged first, pinned, as soon as its own gates are read.
A release is never expedited. A tag is pushed only on a commit whose `ci` check concluded
success; `scripts/ci/release-verdict` refuses anything else, the release workflow asks it
before it builds, and no gate is disabled, skipped or edited to get there. A merge is
corrected by the next merge. A tag publishes executables that people install.
## Repair
The `repair` lane is a person's. Most pull requests here go `CONFLICTING` on the forge within
minutes of any merge to master, and nearly always over `merge=derived` files the forge cannot
resolve, because the derived driver is per-clone configuration. `prs repair <n>` (or the head
branch's name) brings master into one named pull request, whatever lane it waits in, by the
same act as `prs drain --refresh`. It replaces `scripts/unblock`, which did this outside the
integrator, without the lease or the trail.
It decides nothing of its own. It reads the named pull request's assessment and acts only
when the classification says the head lacks master and git's merge with the derived
attribute conflicts on no authored path, the relation `behind`. Every conflict the forge
reports is then on a `merge=derived` path, which the regeneration settles.
| The assessment says | `prs repair` |
|---|---|
| relation `behind` | eligible: master is merged in, derived, committed and pushed |
| relation `conflicting` | refused with exit 10, naming every authored file that conflicts: they are the owner's to settle |
| relation `up_to_date` | nothing to repair: the head already contains master |
| relation `carries_master` | nothing to repair: merging it writes its own tree, and its checks judged that tree |
| `redundant`, `superseded`, relation `contained`, `superseded`, `patch_ids_upstream` or `derived_only` | nothing to repair: cleanup's lane, or a person's |
| relation `unknown`, a fork's head, armed auto-merge, another base, not open | refused with exit 10 |
Without `--apply`, which is the default and is also spelled `--dry-run`, it is a read. It
decides on the queue of the last recorded observation, as `prs status` and `prs explain` do.
It reaches no network, takes no lease, writes nothing to the trail, and exits 10 when nothing
was ever observed. `prs refresh` observes again. With `--apply` it follows the drain's rules:
1. it takes the base branch's integration lease and holds it until the act is over, so a
drain cannot race it, and a second executor started meanwhile is refused (exit 12);
2. it observes the forge again, exactly as a drain step does, and decides again on that;
3. it records `repair_selected` and then `repair_attempted` before anything reaches the
remote, and does nothing when either line cannot be written;
4. in a scratch worktree under the common git directory, never the branch's own, it merges
the master it decided on, runs `scripts/derive`, and commits with the hooks running;
5. it pushes a fast-forward of the head it observed, with a plain push and only while origin
still serves that head, then records `repaired` with the
head it pushed, or `repair_refused` with the failure's class.
A merge, derive, commit or push that fails leaves nothing on the branch and is
`repair_refused`. A branch that moved is classed `stale`. It never merges into master, never
asks the forge to merge, and never touches a person's checkout. A refusal decided from the
classification is recorded too when `--apply` was given, so the trail says why a requested
repair did not happen.
## Cleanup
Closing a pull request requires more evidence than merging one. `prs cleanup` lists the
`redundant` and `superseded` ones and closes them only with `--apply` (`--dry-run` spells
out the default), with a comment that names the reason that decided it, the base, and the
master and head that proved it. The comment has two forms. A redundant one's says its work
is already on the base. A superseded one's opens with its successor (`Superseded by #N,
which landed.`), says that #N was declared its replacement by someone this repository lets
declare one and that the base contains it, and does not claim the closed work is on master.
A closure is taken the way a merge is: the forge is
observed again first, and the pull request is closed only if the second decision still says
the same thing against the same master and head, or the trail records a `stale_decision` and
nothing is closed. The forge is asked for the pull request's state and head just before, and
a head that moved is never closed. `possibly_redundant` is listed and left for a person,
whether its evidence is derived output or a successor merged somewhere master does not
contain. Age, shared paths
and similar titles are not evidence of anything.
Before 0.13 (owner decision D2) the word `superseded` meant what `redundant` means now, and its
closure was recorded as `closed_superseded`. The strong case is `redundant` now, closed as
`closed_redundant`; `superseded` and `closed_superseded` mean only an authorised declared
successor that landed. Older trail lines still read: a `closed_superseded` line written
before carries `head_reachable_from_master` or `merge_changes_nothing`, never
`superseded_by:#N`.
### Branches merged pull requests leave behind
Branches are never deleted here: the forge's `delete_branch_on_merge` decides that (owner
decision D4), so the executor holds no write that removes a branch. What the forge left
behind is reported instead, by `prs cleanup` alone: it reads origin's branches
(`git ls-remote --heads`) and the newest 1000 merged pull requests on demand, and keeps every
branch of this repository that origin still serves at the exact head that merged. `prs
refresh` never asks — the executor runs it before every decision, and a report-only fact
that decides no merge stays off that path; it reads only the setting, from the repository
settings it already asks for. A branch whose tip moved after its merge carries newer work and
is never listed; neither is a fork's branch, whatever it is called, or the base. Cleanup
prints the list after the pull requests and records it as `left-branches.json` beside the
observation, with when it was read, so the surfaces that never reach the network render the
last report with its age:
```text
merged branches left on origin (2); the forge decides deletion, so none is deleted here:
fix/left #1 3f2a9c1d0b7e left_for_a_person
fix/here #3 9c0d4e5f6a7b kept: checked out at /…/here-wt
next: the forge keeps merged branches: enable delete_branch_on_merge, and delete this one with git push origin --delete fix/left
```
A branch checked out in a worktree of this repository is listed as `kept`, with its path:
somebody may still be standing on it. A read that fails leaves the list unread
(`merged branches: unread`), recorded as unread and never as empty. `prs cleanup --format
json` stays the list of pull requests to close.
## Safety
- One executor per base branch: `drain`, `cleanup --apply` and `repair --apply` hold an
exclusive lease at `<git-common-dir>/majordomus/locks/integration-<base>.lock`, with the
holder recorded. Holding it is holding an exclusive `flock` on that file for the
executor's life, so the kernel decides who holds it: of executors started at the same
instant, in one checkout or in several worktrees of the repository, exactly one holds it
and no pull request is merged twice. The integration unit tests race eight executors per
round, and cases 855 and 856 race separate processes through the command line. A holder
that ends, even by a crash, releases the lease at once; a live holder is never taken over.
A record untouched for 30 minutes is reported stale to observers (`prs brief`,
`prs status`), which never take the lease. The executor renews its record before every
observation, and a refresh keeps it fresh while the branch's derive runs. When the path no
longer names the file the executor locked, or the record names another holder, the lease
is lost: the drain stops as on any systemic failure (`prs drain` exits 12) and acts on
nothing. The lease is taken for the base the forge was last observed to name, observed
first when this checkout never asked, never for a guessed `master`.
- One executor per repository, across machines: the lease guards one clone, since every
worktree shares its common git directory and another clone has its own. When this
checkout's shared server runs the repository's mesh (ADR 0067), the executor first takes
an exclusive mesh claim on `integration/<repository>/<base>` as its checkout's session, and
every linked runtime admits against it: a second machine's `prs drain` exits 12 naming the
holding session and what it said it was for, before it takes its own lease. The claim's key
is in the lease record; it is released after the lease. Where no mesh runs, the lease alone
guards, and `prs brief` and the Cockpit say "per-clone guard only". An executor killed
outright leaves its claim held — its session belongs to its checkout, which the mesh never
refuses to itself — and the next executor of that checkout releases the leftover, so a
crash costs other machines a wait, never a claim nobody can give back
(`integration::exclusive`, `tests/integration_across_machines.rs`, case 924).
- Every act is appended to the audit trail before it happens. The trail is one file per
repository, `<git-common-dir>/majordomus/integration/events.jsonl`, beside the lease, so
every worktree writes the same trail and `prs events`, `prs brief`, `prs status`, the
`integration.*` capabilities and the Cockpit read it from any of them. The last queue's
summary (`summary.json`) sits beside it. The observation and the relation cache stay in
the checkout, under `.ai/local/state/integration/`. The recorded observation is schema 4:
it carries each author's association and whether each pull request's cross-references
were read whole. A record of another schema is refused, older or newer, naming both
schemas and `majordomus prs refresh`, which records one this executable reads; it is never
read as empty or as current. An executable of 0.14.0 refuses a schema 4 record the same
way, by its number.
- A read writes nothing. `prs status`, `plan`, `explain`, the `integration.*` capabilities
and the Cockpit build the queue from the recorded observation and leave the checkout as
they found it; only `prs refresh` and the executor, which have just observed the forge,
keep the relations they decided and the summary a briefing reads. What a read decides is
still kept, in the process that read it: a long-running server remembers each relation and
shape by its `master..head` pair, and whether one mirrored head contains another by its
pair of commits, so its next read of the same checkout asks git again only about a head or
a master that moved. Reads of one checkout take turns, so concurrent readers decide each
pair once. `MAJORDOMUS_INTEGRATION_MEMO=off` reads without that memory; the answer is the
same (`integration::memo`). An observation older
than an hour is said in the queue's diagnostics, and every reading of a queue with a
diagnostic — `status`, `plan` and `explain` alike — exits 10.
- The trail is written first. A merge is asked of the forge only after `merge_attempted` is
on the trail, a refresh is pushed only after `refresh_attempted`, a repair only after
`repair_attempted`, a pull request is closed only after `close_attempted`, and a record
pull request is closed and reopened only after `reopen_attempted`. When that
line cannot be written, the act is not taken: the step reports `trail_unwritable`, the
drain stops, and `prs drain` exits 12 (`prs repair --apply` exits 12 too). A lease the
trail cannot record is given back and refused. Any other write the trail refuses ends the
run with the error rather than continuing unrecorded.
- The events, each a typed `action` on one JSON line:
| Event | When |
|---|---|
| `lease_acquired`, `lease_released` | the executor takes and gives back the base branch's lease |
| `continuous_started`, `continuous_stopped` | a continuous drain starts, and stops with its reason |
| `observed` | `prs refresh`, or an executor step, observed the forge |
| `became_actionable`, `left_actionable` | the two transitions a wait is folded from |
| `selected` | the first ready pull request is chosen, with those passed over |
| `refresh_selected` | the first refreshable one is chosen, with its evidence |
| `stale_decision` | master or the head moved between the decision and the act |
| `merge_attempted` | before the merge, with its evidence |
| `merge_succeeded`, `merge_failed`, `verification_failed` | after it |
| `failure_acknowledged` | a person looked at a merge that could not be verified (`prs drain --resume-after-failure`) |
| `refresh_attempted` | before master is merged into the branch and pushed |
| `refreshed` (with the head it pushed), `refresh_failed` | after it |
| `repair_selected`, `repair_attempted` | `prs repair --apply` chose the named pull request, and is about to merge master into it and push |
| `repaired` (with the head it pushed), `repair_refused` (with its class) | after it, or when the classification refused it |
| `close_attempted` | before a redundant or superseded pull request is closed |
| `closed_redundant`, `closed_superseded` (with its evidence), `close_failed` | after it |
| `reopen_attempted` | before a record pull request the workflow's token opened is closed and reopened |
| `reopened`, `reopen_failed` (with its class, saying whether it was left closed) | after it |
| `idle` | nothing was ready |
| `observe_failed` | a continuous drain's cycle met an outage short enough to wait out |
An event may also carry `evidence` (the assessment's, on the acts named above as carrying it),
`class` (why it failed, once classified) and `merge_commit`. Each is left out when empty,
and a line written before they existed reads with them empty.
- A checkout that kept its own trail under `.ai/local/state/integration/events.jsonl` has
it appended to the repository's trail the first time the trail is read while the
repository has none. The marker `events.moved-from` beside the trail keeps that from
happening twice. Old trails of other worktrees are left in place, because appending one
after another would fold their lines out of order.
- A dry run observes and decides and changes nothing. It records only what it observed
(`observed`).
- A refused merge and a stale decision are specific to the candidate: the next step
re-plans. A verification failure stops the drain.
- Every failed act on the trail carries its `class`: `stale`, `conflict`,
`new_failing_check`, `review_revoked`, `transient` or `policy_violation` are one
candidate's, and the drain goes on with the next; `verification_failed` and `unreadable`
stop it. A pull request the forge refused to merge, or whose refresh failed, is shown as
`needs_repair` with `executor_merge_refused:<head>` or `executor_refresh_failed:<master>`
for as long as its head and master are the ones that were refused, so the next ready
change merges instead of the same refusal repeating. A push to the branch or a new master
clears it; a transient failure never holds a change back.
- A signal stops a one-shot drain as it stops a continuous one: the step in progress
finishes, and no other starts. A continuous drain waits out a short outage of the forge
(`observe_failed`, at most three consecutive cycles) before it ends.
- A merge is verified where it landed, not where the forge says it is. The base is fetched,
and the commit right after the decision's master on master's first-parent line must be
this merge: its first parent the master the decision was taken against and, for a merge
commit, its second parent the head that was decided on. A merge that landed on top of
another one, onto a master nobody tested with it, fails as `unexpected_master` even
though the forge calls it merged. The commit is recorded as `merge_commit` on
`merge_succeeded`. This is the executor's half of the guard; the forge's half is the
protection's "require branches to be up to date" (`required_status_checks.strict`). The
queue's policy says whether the base requires it (`up_to_date_required`), and `prs status`
and the Cockpit show it; it decides no disposition, and turning it on is the owner's act
(decision D8).
- A merge whose answer was lost (a timeout, a dropped connection) is asked whether it
landed, never asked to merge again: landed and proved, it is `merge_succeeded`; not
landed, `merge_failed`.
- An executor that stopped between asking for a merge and verifying it leaves a
`merge_attempted` with no end. The next drain verifies that merge first and records how
it ended, before it decides anything.
- A verification failure holds every later drain (owner decision D7). Until a person has
looked and run `prs drain --resume-after-failure`, which records `failure_acknowledged`,
a drain merges nothing, says why, and exits 10; `--continuous` stops. A dry run still
plans.
- Transient failures of the forge are asked again (`crate::integration::retry`): a timeout,
a 5xx, a rate limit or a dropped connection, at most four attempts with waits of 2, 4 and
8 seconds. Anything else, such as a refusal, a 401, a 404 or a moved head, is the answer and
is returned at once. The observation, the fetch and the post-merge verification are
retried. The merge itself is never retried: a merge that timed out may have landed, and
the verification is what finds out. A drain that decides again after a refused merge is
taking a new decision from a new observation, within its step bound, and is not retrying.
## Commands
| Command | Network | What |
|---|---|---|
| `majordomus prs` / `prs status` | no | the ranked queue; exit 10 when the observation is stale or absent |
| `majordomus prs plan` | no | the next merge, the next refresh, and the other lanes |
| `majordomus prs explain <n>` | no | one pull request's gates, reasons, evidence and rank |
| `majordomus prs events` | no | the repository's audit trail, the same from every worktree |
| `majordomus prs trains` | no | the trains of stacked pull requests in landing order: each link's disposition, relation to master, prepared head under `refs/prep/<n>` (pushed or pending) and turn; exit 10 when the observation is stale or absent |
| `majordomus prs brief` | no | one line for a briefing: the last queue built in the repository, the lease and how far it reaches, the last merge, the last refresh, failure or stale decision; nothing in a checkout that never observed the forge |
| `majordomus prs refresh` | yes | observe the forge and fetch every open head |
| `majordomus prs drain [--max N] [--dry-run] [--refresh]` | yes | integrate, one merge at a time |
| `majordomus prs drain --resume-after-failure` | yes | record that a person looked at an unverified merge, then drain |
| `majordomus prs drain --continuous [--interval S] [--max N] [--refresh]` | yes | drain, wait, drain again until stopped |
| `majordomus prs cleanup [--apply]` | yes | close what is provably on master, or superseded by an authorised successor that landed; list what is a person's (possibly redundant, obsolete) and the branches merged pull requests left on origin |
| `majordomus prs repair <n\|branch>` | no | whether master may be brought into that pull request, from the last observation (a dry run) |
| `majordomus prs repair <n\|branch> --apply` | yes | bring master into it, under the lease, as `drain --refresh` does |
The same queue is `GET /api/v1/pull-requests` (MCP `majordomus_pull_requests`), with the
lease and the last merge beside it. One pull request is
`GET /api/v1/pull-requests/explain?number=` (`majordomus_pull_request_explain`), and the
trail is `GET /api/v1/pull-requests/events` (`majordomus_integration_events`). What cleanup
would do is `GET /api/v1/pull-requests/cleanup` (`majordomus_pull_requests_cleanup`): the plan
decided offline from the recorded observation — `would_close` or `left_for_a_person` for each
pull request, the same table `prs cleanup` acts on — beside the branch report `prs cleanup`
last recorded, with its age. It closes, deletes and asks the forge for nothing. All four are
declared once in `capability/builtin/integration.rs`.
The Cockpit renders those two answers at `/cockpit/integration`. It shows the counts by lane,
the executor's throughput over the last seven days (merges, merges per day, the median wait
from actionable to merged, CI rounds per merge, the median cycle, stale decisions, failed and
unverified merges — a median nothing measured is said, not shown as zero), the master every
decision was taken against, the next merge, who holds the lease and how far it reaches, the
last merge, a table per lane with each pull request's reasons, next action and wait, and the
executor's recent actions. With nothing observed, it says so and names `prs refresh`.
`majordomus context` carries `prs brief` under `INTEGRATION`, and a handover derived with
`majordomus handover --derive` carries the same line under `# Current State`, so a session
that continues drain work starts knowing what was merged, what remains, how the last step
went, and whether an executor is running.
## Trains
A *train* is a group of open pull requests stacked on each other: each link stands on the one
below it, and the train lands from the bottom up. The edges are the confirmed dependencies the
classifier already reads ([Dependency markers](#dependency-markers)): a base branch that is
another open pull request's head, or a `Stacked on #N` line, is a `STACKED_ON` edge; `Depends on`,
`Requires` and `Land after` are `DEPENDS_ON` edges. An inferred dependency (a head that merely
contains another) joins nothing. A pull request that declares a dependency on one that is no
longer open is still a train of its own, with that pull request *beneath* it — landed, closed
without a merge, or unread. The decision is ADR 0138; the code is `crate::integration::train`.
Each link carries what the queue decided about it — disposition, lane, reasons, next action,
required checks and every check on its head, relation to master with how far behind it is and
whether it carries master (ADR 0129) — and what only a train can say:
| Field | What |
|---|---|
| `position` | its turn, 1-based: a topological order, lower links first, ties broken by queue rank |
| `depth` | links below it on its longest path to master; a root is 0 |
| `at_turn` | every dependency it declares has landed, so the order allows it next |
| `on`, `carries` | what it stands on, and the links stacked on it |
| `prep` | the head the executor prepared for it under `refs/prep/<n>`, `pushed` when that commit is the head the forge serves and `pending` when it waits for its turn |
| `in_cycle` | caught in a cycle of confirmed dependencies: no link of it can land first |
Per train: `roots` (no open link below them), `tops` (nothing stacked on them), `next` (the
first link at its turn), the `edges` (satisfied when the pull request below landed) and the
pull requests `beneath` it. Beside the trains, `unattached_prep` lists the prepared heads whose
pull request is no link — open and stacked on nothing, or not open at all.
It is all read: the queue from the recorded observation, the bodies and checks from the same
observation, and the local `refs/prep/` refs with `git for-each-ref`. Nothing is fetched,
pushed or moved; pushing a prepared head at its turn stays the executor's act.
```text
$ majordomus prs trains
2 train(s), 5 link(s) of o/r observed at 2026-10-11T04:56:48Z; master at 1393d9ef2a; 1 prepared head(s) pending
train-884 roots #884 tops #898 next #884
1. > #884 needs_refresh behind 37 prep 9658f78da5 pending on master fix(http): ...
2. #898 waiting_for_dependency behind 37 no prep on #884 fix(http): ...
```
The same answer is `GET /api/v1/pull-requests/trains` (MCP `majordomus_pull_request_trains`),
declared once as `integration.trains`. The Cockpit draws it at `/cockpit/trains` in the manner
of the Neo4j Browser: master as the anchor node, each link a circle with its number and title
inside, coloured by its disposition from the design tokens (landed pull requests beneath a
train in their own colour, dashed), labelled `STACKED_ON` / `DEPENDS_ON` arrows and a `TARGETS`
arrow from each root to master; a force layout or a layered one, drag, zoom and pan, the
neighbours of the node under the pointer lit, and a property panel with every field of the
selected link and links to the pull request, its checks and `prs explain`. It filters by train
and by disposition (the legend is the filter), searches by number, title or branch, and is
driven from the keyboard (arrows move from link to link, Enter opens the pull request, `+`/`-`
zoom, `0` fits, Escape clears); motion follows `prefers-reduced-motion`. It asks the route
again once a minute while the tab is visible and shows when it last asked. Without JavaScript
the page is one table per train with the same facts.
## Continuous mode
`prs drain --continuous` drains, waits `--interval` seconds (300 by default, 30 to 900),
and drains again. It holds the base branch's lease for the whole run, so a second executor is
refused while observers are not. The ceiling keeps the wait well inside the 30 minutes after
which a lease is called stale. Every cycle is an ordinary bounded drain: `--max` merges, each
from a fresh observation, and nothing is carried between cycles. Ctrl-C or SIGTERM lets the
step in progress finish, then the drain stops and releases the lease. A second signal ends
it at once. A verification failure stops it for a person, and so does a forge or git that
cannot be read after its retries. It never runs with `--dry-run`, and it does not start before
the rollout's record allows it (see below).
## Rollout
1. **Dry run.** `prs refresh && prs status && prs drain --dry-run` against the real
repository. The classification was checked on 2026-09-30 against 70 open pull requests,
and stage 1 was taken with the released 0.14.0 on 2026-10-06 against 16, moving nothing
([`INTEGRATION_ROLLOUT.md`](INTEGRATION_ROLLOUT.md)).
2. **One merge.** `prs drain --max 1` once a pull request is `ready`.
3. **Bounded.** `prs drain --refresh --max 3`.
4. **Continuous.** `prs drain --continuous` is refused, with exit 10 and before the lease,
the base or the forge is touched, until the audit trail holds five verified merges
(`ROLLOUT_MERGES_BEFORE_CONTINUOUS`) since the last merge that could not be verified. A
merge that could not be verified starts the count again, even after
`--resume-after-failure`, because the record it ends was the evidence. The gate reads
the trail and nothing else: stages 2 and 3 are what fill it (ADR 0101 §13).