Skip to content

Pull requests are integrated one at a time, each against a master observed a moment before

Pull requests are integrated one at a time, each against a master observed a moment before

majordomus://adr/adr-0101proposed integrationgitgithubgovernancesafety

it references

  • put_in_force apps/majordomus-cli/src/capability/builtin/integration.rs (file)
  • put_in_force apps/majordomus-cli/src/commands/prs.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/classify.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/drain.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/exclusive.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/forge.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/mod.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/relation.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/retry.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/tests.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/wait.rs (file)
  • put_in_force docs/INTEGRATION.md (document)
  • put_in_force project.accumulation-is-measured@2
  • put_in_force project.integration-follows-the-current-master@3
  • put_in_force project.land-and-publish@1
  • put_in_force project.no-network-no-eval@1
  • put_in_force test/cases/720_integration_follows_the_current_master.sh (test)
  • put_in_force test/cases/740_integration_is_visible_where_a_person_looks.sh (test)
  • put_in_force test/cases/741_a_continuous_drain_stops_cleanly_and_alone.sh (test)
  • put_in_force test/cases/742_only_an_outage_is_asked_again.sh (test)
  • put_in_force test/cases/861_a_successor_that_landed_supersedes.sh (test)

referenced by

101. Pull requests are integrated one at a time, each against a master observed a moment before

Context

On 2026-09-30 seventy pull requests were open. None was ready by any honest definition, and nothing said so. The repository had three answers to "what can land":

  • the forge's mergeable flag, which this repository cannot use. Derived files are resolved by a per-clone merge driver (merge=derived) that GitHub cannot run, so the forge calls nearly every pull request conflicting over files a generator rewrites anyway;
  • scripts/land, which asked git the right question and then merged every clean pull request onto one integration branch. That is a batch planned from a list that its own first merge makes stale, and a batch's regressions are nobody's in particular;
  • integration batches built by hand (A to E), for the same reason and with the same cost: a red batch had to be bisected by a person.

Meanwhile the mechanics are fixed. Every merge moves the derived artifacts every other branch carries, so every other pull request stops containing master the moment one lands. A pull request that does not contain master cannot be merged without leaving master's derived data stale. The required check (ci) takes hours.

Decision

Pull-request integration is a typed subsystem of the executable (crate::integration), with one canonical state, one classification and one executor.

  1. One canonical state. A PullRequestAssessment per open pull request: what the forge observed, what its head is to master, its disposition, its reasons, its evidence, its risk, its overlaps, and the master and head it was decided against, with the moment the forge was observed. The command line, the HTTP route, the MCP tool and the Cockpit render that value and nothing else. Its vocabulary is typed, so no client parses prose. A reason is a ReasonCode whose wire form is the code or code:payload string it always was: stacked_on:#N, base_is:BRANCH, draft, label:NAME, auto_merge_armed, merge_commit_not_allowed, superseded_by:#N, successor_open:#N, successor_not_landed:#N, successor_unread:#N, head_reachable_from_master, merge_changes_nothing, patch_ids_upstream, only_derived_artifacts_differ, relation_unknown:WHY, conflicts_on:COUNT, depends_on:#N, review:STATE, review_policy_unread, required_check_failed, behind_master:COMMITS, fork_head, required_checks:STATE, no_required_checks, required_checks_unread, dependency_cycle:#N, dependency_closed_unmerged:#N, dependency_unread:#N, declarations_unread, and, on a ready one, contains_master with required_checks_passed or required_checks_skipped. The trail, the OpenAPI string arrays and the Cockpit read the same strings as before, and a code an older trail carries that this list does not name is kept verbatim. Evidence has a typed kind, with the wire words it had (required_checks, review, relation_to_master, dependency, label, draft, base, plus required_check, one per required context, auto_merge whenever the forge has auto-merge armed, repository_settings when the settings allow no merge commit, and supersession, one per declared successor, one per declaration nobody entitled made and one when the pull request's cross-references were not read whole; freshness is reserved), and a source: the forge observation at its moment, or git on a named master and head.

  2. The relation to master is git's. git merge-tree --write-tree with this clone's drivers, and git check-attr merge for which paths are derived, from master's own .gitattributes. A conflict on a derived path is the regeneration's, not a person's. The result is cached by the pair of SHAs, which are immutable. It is decided on exactly the head the assessment names as evaluated: a head that is not in the clone, because it moved during the refresh, is unknown, never the relation of whatever a fetched ref holds now. An attribute read that fails is unknown, never "nothing is derived". Only a pair of full commit ids is a cache key, and an unknown is never cached. Where the merge would conflict or change only derived output, git cherry is asked too: 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 and master moved past it. A partial match never gives it, nor a head with a merge commit of its own in that range (a merge's resolution has no patch to compare), nor a clean merge that changes authored paths, where master lacks something the head carries, such as a landed change master reverted since. Beside the relation, from the same pair of commits and cached under the same key, is what the merge changes by kind (ChangeShape): every authored path it changes or conflicts on, the derived ones, and the version the head declares in the crate manifest when it raises the one its merge base declares. The relation's variants stay as they are; the shape is what overlaps and risk read, so a conflicting head overlaps others over its whole change and not only over the paths it conflicts on. An overlap names its kind: version_bump when both raise the version, release when both change a record under .ai/repo/releases/, and authored for shared authored paths. Risk is high with paths_unknown when the relation is unknown, never "documentation only"; a version bump (version_bump:V), a shared one (overlapping_version_bump:#N) and a shared release (overlapping_release:#N) are high factors; and .ai/repo/releases/ replaces the derived share/version.txt, which the authored paths never contained.

  3. Fifteen dispositions, decided in one order. They are ready (lane ready); needs_refresh, waiting_for_checks, waiting_for_review and waiting_for_dependency (waiting); needs_repair and conflicting (repair); redundant, superseded and possibly_redundant (cleanup); and draft, blocked, unsafe, other_base and unknown (held). The list is a runtime value (PullRequestDisposition::ALL), so a surface that names the dispositions of a lane derives them rather than copying them. ready is reached only when the pull request targets the base, is not a draft, carries no label that holds it, has no auto-merge armed, has no declared successor, is not on master already, the repository allows a merge commit, it contains the current master, has its declared dependencies landed, satisfies the review policy, and has every required check passed on its current head. These are gates, asked in this order: base, draft, label, auto_merge, supersession, relation_to_master, merge_method, dependency, review, no_failing_check, freshness, required_checks. The labels that hold are one table, LABEL_POLICY, each with its effect; the policy copies it and the classifier reads that copy, and no other list of labels exists. Today every effect is hold, compared case-insensitively: do-not-merge, do not merge, blocked, hold, on-hold, wip, manual-merge. No label opts a pull request out of the executor's refresh (owner decision D11). A pull request on which the forge has auto-merge armed is unsafe (auto_merge_armed): the forge would merge it on its own, outside the executor and against whatever master is then, and a refresh would only hand it a head to merge, so nothing acts on it until a person disarms it; the queue names 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 is therefore merge when the repository's settings allow a merge commit (allow_merge_commit), and none otherwise; with none, every pull request the relation does not already decide is blocked (merge_commit_not_allowed, with repository_settings evidence) and the queue says why. The gate is asked 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, says merge_commit_not_allowed after its own reason, and prs cleanup --apply still closes it; so does a superseded one. It never falls back to a squash or a rebase. Every gate is asked whatever the others answered. The assessment's gates lists each with whether it passed, and its reasons hold every failing gate's findings in the same order. The disposition is the first failing gate's, so a draft that also conflicts and fails its check is draft and says all three. A failed required check is asked before freshness and a pending one after it: a head behind master needs a refresh, which runs its checks again, unless a check already failed. A gate that fails only because an earlier one did, such as freshness when the merge conflicts, fails without a reason of its own. The required checks are the base's branch protection and the rulesets that apply to it, together; if either cannot be read, nothing is ready. A check bound to an app (app_id in the protection, integration_id in a ruleset) 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 neither passes the check nor fails it nor holds it pending. A context two sources bind to two apps is required of both. When a context is bound, the refresh reads each check run's app from the forge's GraphQL rollup (checkSuite.app.databaseId); gh pr list does not carry it. A check run of a bound context whose app was not read (the head moved between the two reads, its check suite names no app, or its contexts did not fit the pages that are read) makes that check unknown (required_checks:unknown), never passed, and the queue says which pull requests carry one; a writer read that fails fails the refresh. The binding names the app, not the workflow: every workflow of the repository writes as the same app, one the pull request's own head defines included, so the binding keeps another app out and does not prove which workflow ran. (Amended 2026-10-07: before this, the app was never read, because gh pr list does not report it, and any check run of the name passed.) Of a context's reports, one still running makes it pending, and otherwise the newest completed report is its verdict, so a failure a re-run fixed has passed. A required check that is pending, missing or unreadable, or written by anyone but the app it is bound to, is not passed, and neither is a skip, unless the policy permits that context's skip. A base that requires no check proves nothing about a head: an empty set after a successful read is unknown (no_required_checks), never ready (owner decision D5). The review policy is read from the protection and rulesets the same way (approvals, code owners, stale dismissal), and an approval counts only on the commit it was given on: approvals of an earlier head are stale, and enough of them on the head with a code owner's still owed is code_owners_pending. The forge's review decision comes before the policy: REVIEW_REQUIRED is a pending review even where the policy requires none, because a rule this policy does not list can. A dependency is declared only by a line that opens with Depends on, Stacked on, Requires or Land after and a number; the same words mid-sentence are prose. It is satisfied by a merge alone: one closed without a merge holds the pull request blocked (dependency_closed_unmerged:#N), one the forge cannot account for is unknown (dependency_unread:#N), and declared dependencies that form a cycle among open pull requests block every member (dependency_cycle:#N). A head that contains another open one's head is an inferred dependency: evidence, never a block. A pull request is stacked only on a branch of this repository, never on a fork's branch of the same name. A successor is declared the same way, by the same parser: a line of a pull request's body that opens with Superseded by #N names N as its successor, and a line of N's body that opens with Supersedes #M names N as M's, from the other side. The supersession gate is asked before relation_to_master, because a pull request whose successor landed usually conflicts with what the successor brought, and is superseded rather than conflicting. A declaration counts only from someone the repository lets declare one: the pull request whose body carries it is a branch of this repository and its author is an OWNER, MEMBER or COLLABORATOR, in either direction, tested on the author of the body that speaks. Anyone else's, a fork's, or one whose association was not read is evidence (possible_supersession, naming who said it) and changes no disposition. A successor landed when git finds its head or its merge commit in master, whatever the forge calls it: one merged by a squash, and one closed after a batch carried its head in, both landed. An authorised successor still open holds the pull request (successor_open:#N, waiting_for_dependency): it must not land while its successor may, and once the successor lands it is closed rather than merged. One that landed makes it superseded (superseded_by:#N), and the assessment names N in superseded_by; one closed unmerged releases it to its own relation; one merged somewhere master does not contain leaves it possibly_redundant (successor_not_landed:#N) for a person; one that cannot be read is unknown (successor_unread:#N). A pull request whose cross-references were not read whole is unknown (declarations_unread) before any of that: an unread declaration is never taken for none. Of several successors, one that landed decides, then one still open, then one merged elsewhere, then one unread.

  4. One merge at a time, and no plan survives it. The executor's step takes no plan. It observes the forge, decides, observes again, and acts only if the second decision names the same master and head and still says ready. drain is a loop over that step. There is no variable in which a candidate list could survive a merge. A merge is then proved where it landed: the first-parent successor of the decided master must be this merge, with the decided head as its second parent; anything else is unexpected_master, even when the forge calls the pull request merged. The forge-side half of that guard is the protection's required_status_checks.strict; the executor reports a base without it and cannot set it (D8). An unverified merge holds every later drain until a person acknowledges it (D7); an interrupted merge is verified, never repeated, before the next decision.

  5. Refreshing is a separate, bounded act. When nothing is ready, drain --refresh brings master into the first pull request that needs it: a merge commit with the derived driver and a fresh derive, pushed as a plain fast-forward while origin still serves the head that was observed, never forced and never a rewrite. This is what project.land-and-publish already prescribes, and prs repair <n> --apply is the same act for one pull request a person names, under the same lease and trail; its dry run decides offline on the recorded observation. 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 and waste its CI run. Only a run the executor started holds it: the required check of the head a refreshed event recorded as pushed, while it is pending or missing. Missing counts because an aggregate check is not created until the jobs it needs finish, but it holds only for a bound after the push (four hours), so a check that never reports cannot freeze every refresh. A check on a head the author pushed holds nothing.

  6. Cleanup demands more than merging. Only two dispositions are closed, and only with --apply. A redundant pull request is closed on git's strong evidence: its head is an ancestor of master (head_reachable_from_master), its merge changes no file (merge_changes_nothing), or every commit it has and master lacks is on master as an equal patch (patch_ids_upstream). A superseded pull request is closed because a successor an owner, member or collaborator declared for it landed; the closing comment names that successor and does not claim the closed work is on master. Weak evidence is never closed: one that differs only in derived output, or whose successor was merged somewhere master does not contain, is possibly_redundant and is left for a person. Age, shared paths and similar titles are not evidence. The trail records the closure as closed_redundant or closed_superseded.

    Wire change (owner decision D2, shipped with the 0.13 minor). Before it, the word superseded meant what redundant means now: the strong relation to master, closed by cleanup with the action closed_superseded. The strong case was renamed redundant, its closure closed_redundant, and superseded now means only an explicitly declared successor that landed, with closed_superseded as its closure. A client that matched superseded to find work already on master matches redundant now. Every older line still reads: a trail line with closed_superseded parses, and its reasons (head_reachable_from_master, merge_changes_nothing) say which meaning it carried, since a closure of a declared successor always carries superseded_by:#N. The relation to master keeps its own word, superseded (merge_changes_nothing), which is git's fact, not a disposition.

    Amended 2026-10-07 (decisions D1 to D5 of the K2/K3 audit; proposed, the owner may override). D1 A declaration is authorised by the forge's association of the pull request that carries it (OWNER, MEMBER, COLLABORATOR, same repository), in both directions; unknown is unauthorised. D2 An authorised declaration holds the replaced pull request until its successor resolves: landed, it is superseded and closable; closed without landing, it is released. An unauthorised one is possible_supersession evidence and neither holds nor closes. D3 Landed is git's fact — the successor's head or merge commit is an ancestor of the base — so a successor that reached master inside a batch counts; the forge's MERGED only words the evidence. D4 Declarations about an open pull request are read from its own cross-references (paged) and its own body, never from a bounded search of closed pull requests; a read that is partial holds that pull request (declarations_unread), and one that fails fails the observation. D5 The rule is version 3 and names all four closure grounds: ancestry, a no-change merge, every patch upstream (which version 2's bullet never named, though the code closed on it), and an authorised declaration whose successor landed. Before this amendment any body's Supersedes #M held and closed #M, the closed declarers came from the 200 newest search results, and the observation schema was 3; it is 4.

    Amendment to D3, 2026-10-07 (ruling R1; a narrowing the owner may reverse). Git's fact is trusted only about a head nobody could move to manufacture it. The attack: an owner's open #1 says Superseded by #7, where #7 is a contributor's fork pull request; #1 is held on it, as D2 says. The fork's author force-pushes #7's branch to any old commit of master and closes #7. Its head is then an ancestor of master, and D3 as first written called that landed: prs cleanup --apply closed #1 with none of #7's work on master. So a successor the forge does not report merged counts as landed only when its head lives in this repository and is an ancestor of the base (the batch-landed path); a cross-repository successor closed unmerged never lands, wherever its head points, and releases the hold exactly as one closed without landing. A successor the forge reports merged lands when its head or its merge commit is an ancestor of the base, fork or not. Where a resolved successor's head lives is read with it, and unread is a fork. In the same spirit a successor the forge says changes no file brought nothing to master and never lands, which closes the same trick played with a branch of this repository reset onto master.

    Rulings of 2026-10-07 on D4 and on what counts as a declaration. R3 A declarations read the forge will not answer fails the whole refresh (exit 12) and records no observation: the one recorded before it stays as it was. Only a truncated read is a per-pull-request hold. R4 A line inside a markdown quote (>), a fenced code block or an HTML comment is not its author's declaration; D1 put all trust on whose body speaks, so only what that body states counts. A dependency keeps the lenient reading: it can only make a pull request wait. R5 An open declarer whose repository flag the declarations read did not state is read as cross-repository, and authorises nothing.

    Residuals, 2026-10-07 (known, accepted, the owner's to revisit). R2 Truncation holds, and an outsider can cause it: the page limit counts every cross-reference to a pull request, and a mention by an issue or from another repository is dropped only after it was paged through. Whoever writes more mentions of owner/repo#N than are read (fifty pages of a hundred) holds #N as unknown (declarations_unread) on every refresh; no command releases it, and a person decides it. That is a block and never a closure, it costs the outsider one written mention each, and the rest of the queue keeps moving. No release mechanism is invented here. R6 MEMBER is the forge's word for an organisation's member whatever their access to this repository, and it is evaluated when the observation is made, not when the body was written. A former collaborator who is still a member can edit the body of an old pull request of theirs in this repository to Supersedes #M: if that pull request is open it holds #M, and if it merged long ago #M is superseded and closable. D1 names MEMBER and stands; a personal repository has no members and is not exposed.

  7. One executor per base branch. An exclusive lease under the common git directory: an exclusive flock on its file, held for the executor's life, with the holder recorded beside it for whoever reads. The kernel decides who holds it, so executors started at once cannot both win, and a holder that ends, even by a crash, releases it at once; a live holder is never taken over, however old its record. The staleness bound is what an observer reports, not a licence to take the lease. Observers never take it.

    The lease guards one clone: every worktree shares its common git directory, and a second clone on another machine has its own. The repository is guarded by the mesh (ADR 0067) when this checkout's shared server runs it: before the lease, an executor takes an exclusive mesh claim on integration/<repository>/<base> as its checkout's session, which every linked runtime admits against. A second machine's executor is refused with exit 12, naming the holding session and what it said it was for, before it takes its lease or acts. The claim's key is written in the lease record and released after the lease. A mesh that is not running here refuses nothing: the lease alone guards, and the record, prs brief and the Cockpit say so ("per-clone guard only"). A claim an executor left behind when it was killed is its checkout's own session, which the mesh never refuses to that session; the next executor there takes the lease and releases the leftover, so a crash costs the other machines a wait, never a claim nobody can release.

  8. The network is declared, not tolerated. prs refresh, prs drain, prs cleanup and prs repair --apply run the GitHub CLI and git fetch. Every other surface renders the recorded observation with its age. SECURITY.md names the exception. Besides the open pull requests, an observation reads, with every open pull request, its author's association and the pull requests that mention it (gh api graphql, paged; a mention is a declaration only if that pull request's body says Supersedes), and each successor or dependency an open one names that is not open (gh pr view), and fetches their heads, so that git can say whether a successor landed after it stopped being listed as open.

  9. Every act is recorded. Selections, stale decisions, merge attempts, merges with the master before and after, refusals, refreshes and closures go to an append-only trail that the integration.events capability serves. The trail is the repository's, not a checkout's: one file under the common git directory, <git-common-dir>/majordomus/integration/events.jsonl, beside the lease, so every worktree writes and reads the same one. Each act is appended before it is taken, and an act the trail cannot record is not taken.

  10. Failure classes and retry. Every failed act is recorded with its class: stale, conflict, new_failing_check, review_revoked, transient, policy_violation, verification_failed or unreadable. The first six belong to one candidate, and the drain goes on with the next; the last two belong to the repository or the executor, and the drain stops. A candidate the forge refused to merge, or whose refresh failed, is held back as needs_repair (executor_merge_refused, executor_refresh_failed) while its head and master are the ones refused — folded from the trail, never from a plan — so one refused change does not block the queue. A transient failure holds nothing back. The forge is asked again only on an outage (crate::integration::retry, case 742), a merge never; a continuous drain waits out up to CONTINUOUS_TRANSIENT_LIMIT consecutive outages, each recorded as observe_failed, before it ends.

  11. Continuous mode is a bounded loop of bounded drains. prs drain --continuous holds the lease for its whole run and repeats one bounded drain per cycle, so every action is still preceded by a fresh observation and no plan outlives a merge. Between cycles it waits an interval inside INTERVAL_SECONDS (30 to 900 s: the floor spares the forge, the ceiling keeps a waiting worker well inside LEASE_STALE_AFTER), in one-second slices so a signal is honoured within a second of the step in progress finishing. It stops on a signal, on a merge that could not be verified, and after CONTINUOUS_TRANSIENT_LIMIT consecutive outages. A dry run is never continuous.

  12. Waiting is a fact, and starvation is shown, not traded for. The trail records when a pull request becomes actionable and stops being so, and, on every selection, the actionable pull requests passed over; crate::integration::wait folds those into how long each has waited and how often another was chosen. A pull request passed over STARVING_AFTER times is named as starving. The rank is not changed by it: its 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.

  13. The rollout is staged, and the trail is its record. Stage 1 is a dry run against the real repository (prs refresh, prs status, prs drain --dry-run), which moves nothing. Stage 2 is one bounded merge (prs drain --max 1), stage 3 small bounded drains (--max 3, then --max 5). Stage 4, continuous mode, is refused (exit 10, before the lease, the base or the forge) until the trail holds ROLLOUT_MERGES_BEFORE_CONTINUOUS (five) verified merges since the last merge that could not be verified; such a merge starts the count again, acknowledged or not, because the record it ends was the evidence. The defaults are the safe ones: prs status, plan and explain read; a drain merges only when asked to; cleanup closes nothing without --apply.

Consequences

Throughput is bounded by the required check: one pull request lands per CI run of its refreshed head. That is the true cost of this repository's mechanics, and the planner makes it visible rather than hiding it inside a batch. Parallel speculative refreshes — a merge queue — are a later decision, and they need this one's evidence first.

scripts/land is deleted. scripts/force-merge-all, already dispositioned for deletion by the automation inventory, is deleted with it. scripts/ci/backlog-check --remote measures the backlog through majordomus prs.

Alternatives rejected

  • Trust the forge's mergeable. It is wrong in both directions here: measured CONFLICTING on clean merges and MERGEABLE on a real conflict.
  • Merge every green pull request in sequence from one list. Every merge invalidates the list. The second merge would be decided against a master that no longer exists.
  • Batches. They trade attribution for throughput, and a red batch costs a person a bisection. They remain a person's decision, not the executor's. On 2026-10-16 the owner took that decision: the landing seat lands combined tops (ADR 0167), and the executor described here is unchanged.
  • Merge with --admin. The branch protection's refusal is the answer, not an obstacle.
  • Close pull requests by age or similarity. Neither is evidence that the work landed.

Amendment, 2026-10-15: a merge GitHub makes is guarded by checks, not by strict (I1409)

D8 left required_status_checks.strict to the owner and named it the forge's half of the guard. I1409 asked the narrower question that leaves open: when the forge makes a merge onto a master that moved after the pull request's ci ran, what refuses a stale derived tree before it is published? Measured on 2026-10-15, the protection requires ci with strict: false and enforce_admins: false. The answer is three checks, each on the tree it judges, and none of them is strict.

  1. Before the merge, the required ci. A pull request's run checks out refs/pull/<n>/merge, the merge the forge would make at that moment, and its site job runs scripts/generate-site-data --check and its rust job runs majordomus generate --check there (.github/workflows/validate.yml, job site, step derived site data in sync).
  2. Between that run and the merge, the forge's own text merge. The forge runs no custom merge driver. With merge.derived undefined, git falls back to the text merge, which is what .gitattributes says it does. Every derived tree records one source_hash in site/data/generated/source.json, and that hash covers the declared site inputs: 1497 paths, including the registry projections docs/generated/{registry,graph,openapi, cli,web,changelog}.json. Two sides that each changed a canonical input each changed that line, so the forge reports the pull request as conflicting and does not merge it. A bare repository with no driver configured shows it: git merge-tree --write-tree of master c0e3585dc8 with the heads of #1159 and #1149, both CONFLICTING on GitHub, lists site/data/generated/source.json among the conflicts. If only one side changed canonical inputs, the merged inputs are that side's inputs and its derived data is their projection (case 3048 holds the unrelated merge current).
  3. After the merge, before publication, the merged commit. A push to master runs validate.yml on the merge commit, with the same site and rust checks, and pages.yml, whose deploy job runs scripts/pages build. That command exits 10 on stale site data (cmd_current), and the job runs it before its publish step. The master merge c0e3585dc8 was committed by GitHub. On it, validate run 38635619021 (job 116098148462) passed derived site data in sync, and pages run 38635619039 (job 116105040387) ran build at 21:07:39Z and publish at 21:09:30Z. Stale site data is refused at build, so gh-pages keeps the last current commit and the pages-live gate names the owed publication.

Decision. The guard on the server-side merge path is the ci-time freshness gate, at the three points above. It is not strict required checks, and it is not a new merge-time check. The documents claim exactly that: .githooks/pre-commit guards a merge a person commits, and the checks above guard one the forge commits. No document may claim the hook guards a GitHub merge.

Rejected:

  • strict: true as the guard. It is a repository setting and the owner's act (D8, I1409 non-scope). It would re-run ci for every open pull request whenever master moves, which is the throughput this record already prices at one landing per run. It would also not cover an --admin merge, since admins are not enforced.
  • A merge queue. It is the "later decision" of the Consequences above. It needs this ADR's evidence first and is not owed for derived data, because point 2 already makes the forge refuse the only combination a text merge could get wrong for the site.
  • Re-deriving in the Pages build. I1409 non-scope: generation is not deterministic per tree, so the build would produce a third answer.

Residual, stated rather than claimed away. A derived artifact outside the declared site inputs, such as a generated region of AGENTS.md or a skill under .claude/skills/, can be changed by two pull requests that touch different lines, and the forge can text-merge those changes cleanly into a result that is not a projection. Point 1 cannot see that merge, and point 2 does not cover those files. Point 3's generate --check on the push run is the first thing that refuses it: master goes red before any person relies on it. None of those bytes is published by the site.

Where this comes from

canonical file
.ai/repo/adrs/0101-pull-requests-are-integrated-one-at-a-time-against-the-current-master.md
identity
adr-0101
also served as
MCP resource majordomus://adr/adr-0101 · majordomus entity show adr/adr-0101 · GET /api/v1/entity?uri=majordomus://adr/adr-0101
in the Cockpit
/cockpit/objects/adr/adr-0101

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

Searches the titles and descriptions of every page this site publishes.