Merge pull request 'ADR 0238: a commit is the build at hand — one commit, one change plan, checked off the trunk and published only on it' (#155) from design/0238-one-commit-one-change-plan into main

This commit was merged in pull request #155.
This commit is contained in:
2026-10-06 21:00:50 +00:00
8 changed files with 259 additions and 1 deletions
@@ -9,6 +9,14 @@ extends: 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-t
# 237. A change is judged against the mesh that runs, before it merges, on the build seat
> **Narrowed, not replaced — 2026-10-06, by [ADR 0238](0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md).**
> Decision 4's *which* and *what* moved: a pull request is checked when the mesh's module graph says it
> reaches a module — the planner's own answer, not *a repository it builds a module from into that
> branch* — and every repository of the mesh runs its own `merge-check.sh` as a second status,
> `mesh/repo-check`, a warning when it has none, instead of *the gate alone*. The gate itself is the
> build seat's, no longer each repository's script. The facts, the gate's rules, the replays, where a
> check runs and what it is given stand as decided here.
## Context
**The operator approved starting Phase 5** of [to-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md)
@@ -0,0 +1,186 @@
---
topic: the mesh
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md
extends: 02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md
---
# 238. A commit is the build at hand: one commit, one change plan, checked off the trunk and published only on it
## Context
[ADR 0237](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md) put a
check before every merge and ran it on the build seat. Turning it on for every repository of the mesh, the
same day, showed four places where it asked the wrong question.
- **Which pull requests are checked was the repository's choice, not the mesh's.** The controller checked a
pull request when the mesh built a module from that repository into that branch, and ran the
repository's `merge-check.sh`, or the gate alone. A repository nothing is built from (the decision
records, the lab) was not checked at all, and its pull request kept the forge's *pending* forever. A
repository with no script ran only the gate, and nobody was told its own tests never ran.
- **The gate had its own idea of what a change touches.** The merge handler, the release planner's
what-if, the gate's *rebuild width* and the gate's composition each read a change's files in their own
way. Two of them agreed with each other; none of them was guaranteed to agree with the plan a merge
then made.
- **The rule they shared was wrong.** A file in no module's directory was read as *shared code*, and
everything built from the repository was rebuilt for it. The builder never reads such a file: it clones
the repository and builds within the module's own directory (the whole repository for a module built
from its root), plus any other repository an artifact's recipe names. Adding `merge-check.sh` at the
catalogue's root planned 103 rebuilds ([issue 280](../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md),
*Left open*). [Issue 278](../04-ISSUES/278-a-module-held-by-no-machine-was-read-as-shared-code/00-report.md)
and [issue 252](../04-ISSUES/252-a-merges-changed-modules-were-read-wrong/00-report.md) had each patched
an exception into the same rule.
- **Nothing stopped a commit off the trunk becoming a module's version.** `build --ref <branch>` of a
feature branch registered what it built, and the next push could send it. A pull request's head was
only kept out of the catalogue by the check never asking to register it — a convention.
## Considered Options
**What decides whether a pull request is checked.**
1. *The repository opts in*, with a script or a registration. Rejected: a repository that has not opted in
is exactly the one nobody is watching, and the forge said *pending* for it forever.
2. **The mesh's module graph** — chosen. The controller holds every module, the repository and directory
it is built from, and the build records' other repositories. Every pull request the forge holds is
announced, and the graph says what it reaches.
**Where what a change touches is computed.**
1. *In the gate, beside the planner.* Rejected: two computations of one fact drift, and the drift is found
when a merge does something its check did not say.
2. **In the planner, once** — chosen. One function maps a changed file onto modules, one function answers
what a merge would move and build; the merge handler, the what-if, the gate and the check all ask them.
**What a file outside every module's directory touches.**
1. *Everything built from the repository* (the shared-code rule). Rejected: no build reads it — the cost
was 103 rebuilds for a script at the root.
2. **Nothing** — chosen. A build reads its module's directory and the repositories its recipes name;
a repository a recipe names is the build record's, already followed.
**How a commit off the trunk is kept from being published.**
1. *By convention*: checks do not ask to register. Rejected: a person building a branch by hand registered
it, and a definition nobody had reviewed reached a machine ([issue 240](../04-ISSUES/240-a-dry-run-build-is-recorded-and-rolled-out/00-report.md)'s class).
2. **By refusal in code**: the build seat says which branches hold the commit it built; the controller
records and never registers a build off its module's trunk — chosen.
## Decision
1. **The commit is the build at hand, and where it sits decides what may happen.** A commit **not on the
trunk** — a pull request's head, any branch — is only *checked*: its change plan computed, the gate run
(composed machines validated, replays), its own tests run, the verdict posted. Nothing of it is
registered or sent to any machine; anything built to check it is never registrable. A commit **on the
trunk** is *published* — its builds registered as the module's version — and *pushed*, by the gated
release. **The trunk** is the branch the module follows; for a module new to the catalogue, its
repository's default branch as the forge names it. Enforced in code: the build seat reads, from its own
fresh clone, the default branch and every branch holding the commit, and says them with the outcome;
the controller records a build off its module's trunk and refuses to register it, naming the trunk —
which covers `build --ref`, `rebuild` and `replay --register` alike. A check's or a dry run's outcome
is never registered. A build seat older than this rule says nothing, and its build is registered as
before and said, so the rule can reach the mesh through the build seat it is built into.
2. **One commit, one change plan.** A change plan is computed from a diffset — a repository, the branch it
merges into, the commit at hand — by the planner: the **build plan** (the modules the merge moves
itself, their dependents in tiers; whether each is a move is its build's source fingerprint, issue
280), the **deploy plan** (per machine, what it is sent in the build plan's order; what waits there
for a person; steps that are not an ordinary send flagged: a planned bus step, a provider whose
consumers are sent again, a module keeping data per ADR 0232/0233), and the **verdict** (the composed
machines, the replays). It is one object: a pull request's check posts it as its result, the release
after the merge follows the same planner and says what differs when another merge landed meanwhile,
and `plans` answers it for any base and head. It is kept — its id, its inputs, its result — so the
pull request, the release and the operator refer to the same thing.
3. **The planner maps a change onto the modules, once.** A changed file touches exactly the modules whose
build reads it: a module's own directory, the whole repository for a module built from its root, and
a repository a recipe names (the build record's `read`). A file read by no build — at the root, in a
directory no module is in — touches nothing and is said as such. A directory the change adds a
`module.json` in, which the graph does not hold, is a new module: checked, never built by the merge.
One function does the mapping and one answers the whole reach; the merge handler, the release
planner's what-if, the merge gate and a pull request's check call them, and nothing else maps a path.
4. **The graph decides what is checked, in two layers** (replacing ADR 0237 decision 4's *for a
repository it builds a module from into that branch* and *or the gate alone*). The forge's holder
announces every pull request it holds, with the directories holding a module at its head, the files
it deletes and whether its head has a `merge-check.sh`. The controller answers every one:
- a change that reaches a module, or adds one: the build seat runs **the gate**, status
`mesh/merge-gate` — the touched manifests through `module check` (a problem the base branch already
had is said and fails nothing), every machine composed with the definitions of the modules the plan
moves or adds (not every definition in the tree), the replays — judged by the controller the mesh
runs, a controller change by itself, a node-engine change by the running controller with its
validator;
- every repository of the mesh — one a module is built from on some branch, one of the core's owner,
one the change adds a module to — gets **its own check**, status `mesh/repo-check`: its
`merge-check.sh`, in the toolchain it declares (`# mesh-check-toolchain: go|typescript`); a
repository with none is a **warning**, never a pass and never silent;
- a change that reaches nothing is a pass that says so — a fact, not a missing check — and a
repository outside the mesh is told nothing more.
5. **Required statuses are the operator's setting, applied by the forge's holder.** `mesh/merge-gate` is
required on the trunk of every repository a module is built from, the list derived from the graph;
`mesh/repo-check` as well on the core repositories. The forge module's
`gitea_branch_protection_get` / `gitea_branch_protection_set` read and set it; a new rule refuses direct
pushes.
6. **A change plan is a state machine, one table.** States and the transitions allowed between them,
each with its guard, compiled once; any other transition is refused. `proposed` (a head) → `checked`
→ `rejected` | `ready`; `ready` → `published` (guard: the commit on the trunk, its builds registered)
→ `releasing` (per machine: sent → judging → passed | failed → rolled-back) → `done` | `failed` |
`superseded` (a newer plan for the same repository and trunk) | `stopped` (a person, with why) |
`held` (waits for a person: the release backlog, a bus step, a module whose policy records) →
`releasing` (guard: a person's decision, with why). It replaces the release plans' implicit states
(open, building, judging, done, failed, superseded, stopped, held), so there is one machine, not two.
Every transition is kept with the plan and the commit, said on the bus, and appended to the commit's
note; the plan resumes from its recorded state under the lease after a restart; a state held past its
bound raises a condition, and healer H2 works from the table.
7. **The plan is attached to its commit.** The `mesh/merge-gate` status links to the kept plan and
describes it in a line ("builds gitea → the control node; no bus step; 4/4 compose"). A git note under
`refs/notes/mesh-plan` on the pull request's head carries the plan's id and the predicted summary; on
the merge commit on the trunk, the executed plan — what was built, sent where, the gates' verdicts,
rollbacks, timings — written by the mesh's own forge account after the release, never by a person's
or an agent's hand. The note writer appends: running twice adds nothing, and an outcome amends the note,
never history. `git log --notes=mesh-plan` shows each commit's plan.
## Consequences
- **A module built from a branch other than its repository's default keeps that branch as its trunk.**
Three applications are built from such a branch; a module new to the catalogue from one is refused until
the branch is the default or merged into it.
- **The build seat must be updated before the trunk rule bites.** Until the build seat built from this
change is held, it says nothing and its builds register as before, said in the controller's log.
- **The repository's own check runs code nobody has approved, and is given nothing to leak**: no container
runtime socket, no package-registry credential. A suite that needs the mesh's own packages from the
registry says those tests did not run; the Go toolchain gains a C compiler (the race detector) and Python
(the decision records' checks), and the TypeScript toolchain git.
- **A rebuild is narrower**: a file at a repository's root rebuilds nothing; the release planner and the
gate say the files no build reads.
- **What is decided here and not yet built** is listed in to-be 45's Phase 5 and stays there until it is:
the kept change plan and its comparison at release, the state machine replacing the release plans'
states, the notes, the status's link.
## How it is checked
| Rule | Checked by |
|---|---|
| a commit off its module's trunk is recorded and never registered; a check or a dry run never is | the controller's test of the publish rule (on the trunk, off it, a module following another branch, a feature branch named by hand, a check, a dry run, a build seat that says nothing); the builder's test reading the trunk and the branches holding a commit from a fresh clone |
| one function maps a changed file onto modules, and every reader asks the planner | the controller's tests that a pull request's reach is the merge's (moved, dependents, width, unread) for a root file, a module's directory and both; the merge handler's and the gate's width tests on the same rule; a review of the callers, listed in the pull request |
| a file no build reads touches nothing; a repository a recipe names reaches its packager | the merge-handler tests (a root file, a directory with no manifest, a file among the modules), the gate's width test (a root file and a README rebuild nothing), the check's test of a packaged repository |
| every pull request is answered: a gate when it reaches a module, a repository check for the mesh's repositories (a warning without a script), a pass that says so otherwise | the controller's scope tests; the builder's test of a check with nothing to run; the builder's live tests (a script run beside the mesh's versions, failing, in a toolchain the mesh does not hold, past its bound, the gate with a stand-in judge) |
| a manifest problem the base already had fails nothing | the builder's live gate test with a manifest broken on the base branch |
| the statuses and the plan reach the pull request; an error is never a success | the forge module's tests of both statuses, the plan's line and comment |
| the change plan says what each machine receives and what is not an ordinary send | the controller's change-plan test (a module's own change, the bus with its dependents and a module that waits, a root file) |
| a branch protection rule is set as asked and a new one refuses pushes | the forge module's protection tests |
| the state machine refuses a transition not in its table; notes are append-only | not yet built: a test walking the table, as the signals and healers tables are walked; the note writer's idempotence test |
## References
- [ADR 0237](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md),
[ADR 0236](0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md),
[ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md).
- [to-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md) §9 and Phase 5;
[to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md).
- Issues [252](../04-ISSUES/252-a-merges-changed-modules-were-read-wrong/00-report.md),
[278](../04-ISSUES/278-a-module-held-by-no-machine-was-read-as-shared-code/00-report.md),
[280](../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md).
- mesh-controller #101, mesh-catalog #98, mesh-host #43, mesh-tools #18, mesh-tools-go #1, mesh-sdk #11,
mesh-lab #53, mesh-media-catalog #12, and the applications' own checks.
+1
View File
@@ -206,6 +206,7 @@ python3 00-META/checks/index.py fail if stale
- **0234** — [The mesh holds a conversation with its operator, over channels that are seats, and an answer that performs an action is authorised by the controller](0234-the-mesh-holds-a-conversation-with-its-operator.md)
- **0236** — [A build is judged on its first machine and put back by something other than itself, and so it rolls out unattended](0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md)
- **0237** — [A change is judged against the mesh that runs, before it merges, on the build seat](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)
- **0238** — [A commit is the build at hand: one commit, one change plan, checked off the trunk and published only on it](0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)
### Its tiers, from the bottom up
@@ -4,6 +4,7 @@ status: proposed
code: []
updated: 2026-10-06
decisions:
- 02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md
- 02-DECISIONS/0221-a-push-sends-no-build-a-policy-or-a-plan-holds-back-except-to-the-machine-it-names.md
- 02-DECISIONS/0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md
- 02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
@@ -159,6 +160,15 @@ code. From an announcer that does not list them, the rule above stands. A change
rebuilds the build agent alone: what it builds is ordered after it in a plan, never added to one for its
sake.
Revision, [ADR 0238](../../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)
(2026-10-06), closing [issue 280](../../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md)'s
*left open*. **There is no shared code.** A changed file touches exactly the modules whose build reads
it — the module's own directory, the whole repository for a module built from its root, a repository a
recipe names — and a file no build reads, at the root or in a directory holding no module, rebuilds
nothing. One function maps a changed file onto modules and one answers what a merge moves and builds;
the merge handler, the what-if, the merge gate and a pull request's check ask them. Only a commit on the
module's trunk is registered.
## What a push does not send (2026-10-05)
Revision, [ADR 0221](../../02-DECISIONS/0221-a-push-sends-no-build-a-policy-or-a-plan-holds-back-except-to-the-machine-it-names.md).
@@ -4,6 +4,7 @@ status: in-progress
code: [mesh-controller, mesh-host, mesh-tools, mesh-catalog, mesh-sdk, mesh-lab]
updated: 2026-10-06
decisions:
- 02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md
- 02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md
- 02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md
- 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
@@ -512,6 +513,22 @@ makes any machine fail to compose or validate fails its check, naming the machin
module. The resolver module's tests run under both C libraries the snapshot lists. A test in each core
repository asserts that a pinned dependency's version equals the version the snapshot says runs.
Revision, [ADR 0238](../../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)
(2026-10-06). **The commit is the build at hand.** A commit off its module's trunk is only checked; a
commit on it is published and pushed, and the controller refuses to register a build of one off it. **The
module graph, not the repository, decides what a pull request's check runs**: every pull request the
forge holds is announced, and the controller asks the planner — the one function that maps a changed file
onto modules, and the one answer of what a merge moves and builds — what a merge of the head would reach.
A changed file touches exactly the modules whose build reads it; a file no build reads touches nothing. A
change reaching a module, or adding one, runs **the gate** on the build seat (`mesh/merge-gate`): the
touched manifests through `module check`, failing only what the change brings; every machine composed with
the definitions of the modules the plan moves or adds; the replays. Every repository of the mesh runs **its
own** `merge-check.sh` (`mesh/repo-check`) in the toolchain it declares, a warning when it has none. The
check's result is the commit's **change plan** — the build plan, the deploy plan machine by machine with
what is not an ordinary send, and the verdict — posted on the pull request. The plan is one object with
a state machine, kept, followed by the release and written to the commit as a note; which parts are built
is Phase 5's.
**The replays.** mesh-lab carries a scripted scenario for each core incident, asserting the rule's
outcome, run on every merge to mesh-controller, mesh-host and mesh-tools:
@@ -702,6 +719,24 @@ rest of the window's incidents as replays; the merge gate's first runs against t
the status required to merge, which is the operator's setting on the forge; mesh-tools' and mesh-sdk's
`merge-check.sh`.
**Revised 2026-10-06** ([ADR 0238](../../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md),
the operator's "enable the checks on every repository of the mesh", "the logic acts on our module graph"
and "the commit is the build at hand"), on branches, not yet merged:
| Repository | Delivers |
|---|---|
| mesh-controller | the planner's one mapping of a changed file onto modules and its one answer of a merge's reach, asked by the merge handler, the what-if, the gate and the check; a file no build reads touches nothing (issue 280's *left open*); every pull request answered — the gate when it reaches a module, the repository's own check for the mesh's repositories, a pass that says so otherwise; the gate run by the build seat with its judge chosen from the graph, composing the plan's definitions only, failing only a manifest problem the change brings; the change plan computed and carried with the verdict; a build of a commit off its module's trunk recorded and never registered; its own manifest naming every verb of its seat |
| mesh-catalog | the forge's announcer saying, with every pull request, the directories holding a module at its head, the files it deletes and whether it has a `merge-check.sh`; both statuses and the change plan on the pull request; `gitea_branch_protection_get` and `gitea_branch_protection_set`; the catalogue's own check |
| mesh-host, mesh-tools, mesh-sdk, mesh-lab, mesh-media-catalog, and the applications built from their own repositories | each its own `merge-check.sh`; mesh-tools' tests each on a bus of their own |
| mesh-tools-go, mesh-tools | a C compiler and Python in the Go toolchain, git in the TypeScript one |
| hq | its own `merge-check.sh`, running `records.py`, `index.py` and `cycle.py` |
**Not yet:** the change plan kept (id, inputs, result) and the release comparing its plan with it; the
state machine replacing the release plans' states, with its table, its test, its conditions and healer H2;
the status's link to the kept plan; the notes under `refs/notes/mesh-plan`; check builds in a scratch
namespace (the check builds no module today, so there is none to keep apart yet); the statuses made
required — prepared as calls of the forge module's tool, applied by the operator.
## What is not decided here
- The bus as a cluster of three, to upgrade it live.
@@ -72,6 +72,11 @@ still treats such a file as shared code and rebuilds everything built from the r
fix that costs build time and no longer moves anything. Narrowing it belongs to the controller's
planning rule (issue 278's area) and is not done here.
> **Closed — 2026-10-06, by [ADR 0238](../../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)**
> (mesh-controller #101): a changed file touches exactly the modules whose build reads it, and a file at
> the root rebuilds nothing — in the merge handler, the release planner, the merge gate and a pull
> request's check, through one function.
## How it is checked
| Rule | Checked by |
+3 -1
View File
@@ -47,7 +47,9 @@ vocabulary — *controller* (not "control plane"), *foundation* (not "substrate"
## Ground rules
- **Markdown only.** No new top-level folders without explicit confirmation.
- **Markdown only** — but for the checks in `00-META/checks/` and `merge-check.sh` at the root, which
runs them on every pull request as its `mesh/repo-check` ([ADR 0238](02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)).
No new top-level folders without explicit confirmation.
- **Status lives in YAML frontmatter** — on research overviews (`status`, `became`), design
docs (`layer`, `status`, `code`, `updated`), issue reports (`status`, `located-in`,
`fixed-by`, `amended-design`) and decision records (`status`, `date`, `deciders`).
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# mesh-check-toolchain: go
#
# This repository's own check (ADR 0238): the second layer of a pull request's merge check,
# `mesh/repo-check`, run by the build seat in the mesh's Go toolchain, which carries Python for it. No
# module is built from here, so no gate runs (`mesh/merge-gate` says the change touches none). The three
# checks every merge here must pass (AGENTS.md): the records' structure, the reading order, the cycle.
set -eu
python3 00-META/checks/records.py
python3 00-META/checks/index.py
python3 00-META/checks/cycle.py