ADR 0267 (proposed): an artifact is rebuilt only when its build source changes
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

A merge to a shared repository moved every module that read it, in a gated
tier each (issue 338). The operator's rule maps a change onto the artifacts
whose build source it touches and adds only modules with a build dependency
on a changed artifact; sharing a repository is no dependency. Supersedes in
part ADR 0162 decision 1 (the packages kind) and ADR 0238 decision 3 (whole
repository and context reads); adds build source to the glossary.
This commit is contained in:
jochen
2026-10-08 23:05:51 +02:00
parent d0be529194
commit 7d3f4407a7
5 changed files with 282 additions and 0 deletions
+9
View File
@@ -300,6 +300,15 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
([ADR 0156](../02-DECISIONS/0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)).
An image is one kind of artifact, and a module is not an image. A package and an artifact are two
protocols, not one store being weak ([ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md)).
- **build source** — the files one artifact is built from, in one or more repositories: for a Go program
its import closure (the directories of its packages, its Go module files) and its recipe; for any other
artifact the paths its manifest names, by default its module's own directory. Each artifact of a build
has its own, and a change rebuilds the artifacts whose build source it touches
([ADR 0267](../02-DECISIONS/0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md),
proposed). Older records (ADR 0015, ADR 0111) and designs say *build source* for the repository a module
is built from; this entry narrows it to the files within, and that use reads as *the repository holding
the build source*. Sharing a repository is not a dependency, and no build source is a whole repository
holding another module's files.
- **catalogue** — what the mesh holds of every module, at which commit; and the repository the
modules live in. `mesh-catalog` and `catalog_modules` are identifiers and keep their spelling.
*Not:* ~~catalog~~ (hq)
@@ -11,6 +11,13 @@ extends: 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
> **Progressive insight — 2026-10-02.** The context below says a dependent is *built by whichever build machine is running — the only one there could be*. That was a fact of the day, not of the decision: since [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) a tier's asks are taken by every machine holding the build seat. The plan and its tiers are unchanged.
> **Superseded in part — 2026-10-08, by [ADR 0267](0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md) (proposed; it takes effect when it is accepted).**
> In decision 1, the `packages` kind ("the module's build reads the other's repository") and the *code*
> dependency ("B packages A's source … rebuilt whenever A changes, in the same tier") no longer hold: a file
> two modules build from moves both through their build sources, and no edge is drawn for sharing a
> repository. `stands-on` and `declared` name an artifact at each end. The build and runtime dependencies,
> `built-by`, and decisions 2 to 4 stand as decided here.
## Context
A merge on the forge reaches the controller as an event, and the controller asks the build
@@ -16,6 +16,11 @@ extends: 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-t
> no core module starts on that module's word, and *held* is a delivery's state, released by a person through
> `mesh-delivery`'s `release`. These are the gate rules ADR 0239 applies when a member of a delivery group fails.
> **The mechanism changed — 2026-10-08, by [ADR 0267](0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md) (proposed; it takes effect when it is accepted).**
> What stands: a rebuild that changes nothing is no move. What moved: the source fingerprint of the issue 280
> insight below is taken per artifact over its build source, not per build over the module's directory and
> each context's whole repository, and an artifact with an equal fingerprint is not built again.
## Context
**The operator said "start phase 4"** of [to-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md)
@@ -18,6 +18,12 @@ extends: 02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-
> leads to the delivery's view on the pull request. The *change plan* is called the **delivery plan**. Decisions
> 1 to 5 stand as decided here.
> **Superseded in part — 2026-10-08, by [ADR 0267](0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md) (proposed; it takes effect when it is accepted).** In
> decision 3, "the whole repository for a module built from its root, and a repository a recipe names (the
> build record's `read`)" no longer holds: a changed file touches the artifacts whose build source contains
> it, and through them their modules. The one mapping function, a file read by no build touching nothing,
> and a new `module.json` being a new module, stand.
## Context
[ADR 0237](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md) put a
@@ -0,0 +1,255 @@
---
topic: building it
status: proposed
date: 2026-10-08
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
- 0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md
extends: 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
---
# 267. An artifact is rebuilt only when its build source changes, and a module moves only along build dependencies on artifacts
## Context
The operator stated the rule on 2026-10-08, in their words:
> If only files for module A changed, only module A is taken into the deploy plan. Then the deploy plan
> checks our module graph to check which modules have a dependency on the changed module. However, 1
> commit might change files from more than 1 module in a repository.
> a change to A … moves A and all modules with a build dependency on A
> you should only check the build dependencies in the module graph in our module catalog
And: sharing a repository is not a dependency — "there is no repository dependency". What the operator
calls the *deploy plan* is, in the glossary's words, the delivery plan's **build plan**: the modules a
delivery moves and their dependents, in tiers. The glossary's *deploy plan* is only its per-node part.
### How a merge reaches the nodes today
This description was read from the records and from mesh-controller at `8170fc5`, and confirmed by the
operator before this record was written.
- **A pull request is a delivery** ([ADR 0239](0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)):
checked by the merge gate and the repository check ([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) §4,
[ADR 0237](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)),
published once merged on the trunk, and walked: one node first, judged at the first-node gate, put back
if it fails ([ADR 0218](0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.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)).
- **What moves** is computed by one function, ADR 0238 §3: "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." Dependents are added along the catalogue's one relation
([ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md) §1), with four kinds: `stands-on`,
`declared`, `packages` ("the module's build reads the other's repository") and `built-by`, which
"orders and gates but never widens".
- **A build is one module at one commit, and makes every artifact its manifest declares**, every time, in
name order (the builder's `build`). The recipe and the compiler see the module's own directory ([issue
280](../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md)); a module
built from its repository's root sees the whole repository; an artifact's `context` clones another
repository whole ([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md): "A build
context may live on the git seat").
- **A build produces artifacts, and most are bundles.** [ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md):
"An artifact is anything a build produces … *Image* is one kind. A module is not an image; a module may
build several artifacts and install none." The kinds in the code are `bundle`, `archive`, `image` and
`upstream`, served by the artifact store by digest, and `package`, published to the package registry by
version ([ADR 0075](0075-two-stores-and-which-provides-what.md), [ADR 0076](0076-the-sdk-is-a-published-package.md)).
On the catalogue's trunk on 2026-10-08, 99 of 119 modules build something; they declare 97 bundles, 6
archives, 5 images and 3 upstream copies, no package; 9 modules build more than one artifact.
- **The facts already name artifacts.** A resource names the artifact it runs by its local name, resolved
to a digest when the build is registered. `build.on` names a base as a module *and* an artifact, and a
build records what it stood on as `artifact-store://<module>/<artifact>@<digest>`. A send recreates per
resource: the walk of 2026-10-08 said "recreates 1 of route-proxy's 3 container(s) (with a new image:
server)". **Three things reduce that to modules**: the inventory's edges (mesh-controller
`internal/inventory/dependencies.go` keeps `on.Module` and cuts a recorded base at its first `/`), the
build request (one per module), and the source fingerprint (one per build).
- **A new build is a change unless its fingerprint repeats** (ADR 0236, its note on issue 280):
"Such a rebuild is registered with the artifacts of the build it repeats, so no machine is sent a new
digest." The fingerprint hashes the module directory's tree, **each context's whole repository tree**,
every base by digest and the toolchain. A build that resolves packages from the registry has none, and
is compared by its artifacts' digests.
### What went wrong
Issue 338
(hq pull request #231, not yet merged when this was written). Two catalogue modules, `route-proxy` and `build-agent`,
build an image from a recipe whose context is the controller's repository. Every merge to that
repository on 2026-10-08 planned the same three modules in three gated tiers, whatever the files:
- `mergeCandidates` and `readsFrom` (mesh-controller `cmd/mesh-controller/upgrades.go`) add a module
whose build read the merged repository with no test on the changed paths; `touchedBy`, the function
ADR 0238 §3 names, is never applied to it.
- `dependencies.go` draws a `packages` edge from it to every module built from that repository. So the
`packages` edge is a **context read**, nothing to do with the `package` artifact kind, and it treats
sharing a repository as a dependency.
- **The live tiers come from the code, not from ADR 0162.** That record puts a code dependency "in the
same tier"; the planner put each in a tier of its own (mesh-controller → build-agent → route-proxy,
the last ordered by `built-by`). Each tier waited for its own first-node gate, about three and a half
minutes. Seven completed controller walks took 10 to 11 minutes in gates alone, about 15 minutes in
all, against 4 to 5 for the controller's own tier.
- **The fingerprint is wider than what the build compiles.** It hashes the context's whole tree, so no
two builds repeat. The route proxy's program, built at each of nine merges with `-trimpath
-buildvcs=false`, changed at two of them; it was recreated on its node at five of seven walks.
- **A file in a module's directory is not the right test either.** The merge that did change the route
proxy's program changed nothing under its directory: it changed `internal/broker`, which the proxy
imports. A program's source is its import closure, not its directory.
- The controller is built from its repository's root, so a change to the route proxy alone, or to
`README.md`, moves the controller too.
The controller's planner rules are held by tests since mesh-controller #167
(`cmd/mesh-controller/planner_rules_test.go`, `planner_records_test.go`); the rows tagged *issue 338*
assert today's repository-grain behaviour and say they change with this decision.
[ADR 0009](0009-modules-and-the-graph.md) already said what the build edge should be: "derived rather
than declared … a declared list of dependencies drifts from the imports it describes", and "Reading build
edges needs a language-aware tool per language, which is the real cost". The code derives the edge from
the bases a build was handed, and draws none from imports.
## Considered Options
**What a change is mapped onto.**
1. *Keep repository grain* (today). Rejected: it is issue 338. A module reading a repository moves on every
merge to it, and each such module costs a gated tier.
2. *Module grain, narrowed*: one build source per module, edges module to module, one fingerprint per
build. The smaller change, and honest about its cost: 9 of the 99 modules that build something build
more than one artifact, and with the fingerprint's repeat rule an unchanged artifact rebuilt with its
module is still sent nowhere. Rejected because the operator's rule is about what a build *produces*,
and the mesh already records the artifact on every edge and every resource; module grain is the
planner discarding what the records hold. A module built against one artifact of another would move
whenever any other artifact of it changed — the same widening as issue 338, one level down.
3. **Artifact grain** — chosen. Each artifact has its own build source and fingerprint; a dependency points
at an artifact.
**How an artifact's build source is known.**
1. *Hand-listed paths, checked against the import closure.* Rejected for Go: a list drifts, and a path
missing from it is a real change missed, which is worse than a needless rebuild. The check that would
catch the drift has to compute the closure anyway, so the list adds nothing but a way to be wrong.
2. **Derived from the import closure for Go** (`go list -deps`) — chosen; ADR 0009's "derived rather
than declared", for the language the mesh's own code and 97 bundles are written in.
3. **Declared paths for anything without a reader** — chosen, and made loud: the builder hands such a
build only its declared paths, so a missing path is a failed build, not a missed change.
**Other remedies.**
1. *Split the route proxy and the build seat's program into repositories of their own.* Rejected: they
import the controller's packages, which would move to the SDK, and a change there then moves all three
through a real build edge; the root-built controller would still claim every file. The rule has to
hold whatever a repository's layout is.
2. *Reproducible builds alone.* Rejected as the remedy: the planner would still plan and gate three tiers,
and the gates are the cost (7 of about 11 minutes). Kept as one rule of this decision, for the builds
whose fingerprint cannot be narrow.
## Decision
1. **Every artifact has a build source**: the files it is built from, in one or more repositories. One
build may produce many artifacts, of any kind, and each has its own.
- **An artifact that compiles a Go program** (a Go bundle, or an image or package whose recipe compiles
one) has its build source **derived**: the directories of every package in the program's import
closure inside the repositories it reads (`go list -deps`, the standard library left out), its Go
module files (`go.mod`, `go.sum`, `vendor/modules.txt`), and its recipe. The manifest names the
program's package where it does not already: a Go bundle's `from` does; an image whose recipe
compiles Go gains a field naming the package it compiles.
- **Any other artifact** has the paths its manifest names, within its module's repository or its
context. Absent, it is the module's own directory — today's rule — or, for an `archive`, its `from`.
- **A build source is never a whole repository that holds another module's files.** A module built
from its repository's root (the controller) and an artifact built from a context (the route proxy,
the build seat's program) are narrowed by rule 1's derivation or by named paths. A manifest whose
build source would be such a repository is refused by the catalogue's check.
- The builder records each artifact's build source with the build. A Go closure can only change by an
edit to a file already in it (an import line in one of its packages, or a module file), so the build
source recorded for the trunk's last build is enough to map the next change. An artifact with no
recorded build source (its first build) is built.
2. **A change moves exactly the artifacts whose build source it touches.** The planner's one mapping
function (ADR 0238 §3) maps each changed file onto artifacts, then onto the modules that build them.
One commit may touch artifacts of several modules, in one repository or across the repositories a
build source spans. A change to a module's manifest moves that module; it rebuilds only the artifacts
whose own declaration or build source changed.
3. **A dependency points at an artifact.** A build dependency is an edge from a module's artifact to the
artifact it is built against: a base named in `build.on`, a base the build recorded standing on, a
bundle's toolchain, a package resolved by version. The plan adds, transitively, every module with a
build dependency on a changed artifact, as the catalogue's module graph states it, in the next tier
after that artifact is built. A dependent is planned when its base's fingerprint changed and **left
out when the base is built to the same digest**.
4. **Nothing else moves a module.** Not sharing a repository, not a whole-repository context, not a file
outside every build source. The `packages` edge kind is removed. `built-by` stays as ADR 0162 left it:
it orders the modules a plan already holds after the build seat's holder and never adds one.
5. **An artifact whose build source did not change is not rebuilt as a new digest and not sent.** Its
fingerprint is computed per artifact, over its build source (the git object of each of its paths), the
bases it stands on by digest, its recipe and its toolchain. An equal fingerprint keeps the digest the
mesh holds, and the artifact is not built again. A module is sent only when an artifact it runs has a
new digest or its definition changed; one whose artifacts are all unchanged is not recreated.
6. **A build is reproducible where its fingerprint cannot be narrow.** An unchanged build source gives an
identical artifact, for every kind: nothing in an artifact varies with the commit, the clock or the
path it was built at. For Go: `-trimpath` (the toolchain has it) and `-buildvcs=false`, and no `.git`
in what a recipe sees. This matters for a build with no fingerprint (one resolving packages from the
registry) and for any build source wider than what is compiled. It is not the remedy for issue 338;
rules 1 to 5 are.
### What this supersedes, and what stands
- **ADR 0162 §1, in part.** The `packages` kind ("the module's build reads the other's repository") and
the paragraph's *code dependency* ("B packages A's source — means B is rebuilt whenever A changes, in the
same tier") no longer hold: a shared file is in both artifacts' build sources and moves both by rule 2,
and no edge is drawn for it. `stands-on` and `declared` hold, but name an artifact at each end, not a
module. The *build* and *runtime* dependency tier rules, `built-by`, §2 (the plan is a record), §3 and §4
stand.
- **ADR 0238 §3, in part.** "the whole repository for a module built from its root, and a repository a
recipe names (the build record's `read`)" no longer holds: a changed file touches the artifacts whose
build source contains it, and through them their modules. That one function maps every path, that a
file read by no build touches nothing, and that a new `module.json` is a new module, stand.
- **ADR 0236's insight of issue 280, as a mechanism.** "two builds of a module with one fingerprint as one
build" becomes one artifact with one fingerprint. The decision it supports — a rebuild that changes
nothing is no move — stands. This is a mechanism change, not a supersession.
## Consequences
- **The manifest schema grows two fields on an artifact**: the paths of its build source within its
repository or context, and, for an image or package whose recipe compiles Go, the package it compiles.
Their names are settled at the build handoff. `route-proxy` and `build-agent` take the second; the
controller's own manifest builds `cmd/mesh-controller` as a Go bundle and is narrowed by derivation,
so `README.md`, `examples/route-proxy` and `cmd/mesh-builder` stop moving it.
- **The builder builds and fingerprints per artifact.** It derives a Go artifact's build source, records
it and the fingerprint with each artifact, hands a declared-path build only those paths, and skips an
artifact whose fingerprint the mesh holds. One build request may still name a module; what it builds
inside it is per artifact.
- **The planner** drops the packaging kind in `mergeCandidates` and `readsFrom`, and the `packages`
addition in `reachOfMerge` and `SourceMoved`; it maps files onto artifacts through the recorded build
sources, and confirms a dependent after its base is built.
- **The inventory's relation** (`internal/inventory/dependencies.go`) draws edges between artifacts, keeps
`on.Artifact` and the recorded base's artifact segment, and loses `EdgePackages`. `moveFacts.identical`,
which today compares catalogue commits, compares digests.
- **A controller merge that changes only the controller's program** plans one module in one tier. A change
to a package all three programs import plans all three: the build seat's holder first (`built-by`),
then the controller and the route proxy together, two tiers where there were three.
- **What got harder**: a Go program's build source needs the Go toolchain to read, on the builder; a
second language gets a reader of its own or declared paths. A declared-path build that reads a file it
did not declare now fails where it used to succeed.
- **Design** [to-be 47](../03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md) and
[to-be 10](../03-DESIGN/01-to-be/10-delivery.md) name this record when it is accepted, and the build
handoff opens on mesh-controller and the catalogue.
## How this is checked
| Rule | Checked by |
|---|---|
| A change moves exactly the artifacts whose build source it touches; a file in none moves nothing | mesh-controller `planner_rules_test.go`, `TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt`: rows for a file in one artifact's build source, a file shared by two modules' build sources, a `README.md` at a root-built module's root |
| Sharing a repository moves nothing (issue 338) | `TestASharedRepositoryMovesWhatPackagesItAsItDoesToday` in `planner_rules_test.go` and `TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday` in `planner_records_test.go`: the rows tagged *issue 338* flip — a `README.md` change plans no module, a change to the proxy's package plans the proxy alone, a shared package plans three modules in two tiers |
| A plan is the touched artifacts' modules and what is reachable along build dependencies, and nothing else | the property test `TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges`, with `packages` removed from the widening kinds and edges at artifact grain |
| A dependency points at an artifact; a dependent of an unchanged artifact of a changed module does not move | a new inventory test over a fixture: a module with two artifacts, a dependent built against one; a change to the other plans no dependent |
| A dependent whose base is rebuilt to the same digest leaves the plan | a new planner test: a base rebuilt with an equal digest; the dependent's tier asks nothing |
| A Go artifact's build source is its import closure | a builder test over a fixture repository: the derived build source equals `go list -deps` for the program, and grows when an import is added |
| No build source is a whole repository holding another module's files | a catalogue check in `merge-check.sh`: every manifest's artifacts derived or declared, and a root or context build source refused |
| A declared-path build sees only its paths | a builder test: a recipe reading an undeclared file fails, naming it |
| An unchanged build source is not rebuilt and not sent | a builder test: two builds at commits differing outside an artifact's build source give one fingerprint and one build; a walk test: no send for it |
| A build is reproducible | a builder test per kind: building one source twice gives one digest |
| Live | the `plans` what-if for the controller's repository with only `README.md` answers no module; with the proxy's package alone, `route-proxy` alone |
## References
- Issue 338 and its diagnosis (hq pull request #231); issue [280](../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md)
- [ADR 0009](0009-modules-and-the-graph.md), [ADR 0072](0072-two-graphs-and-the-build-chain.md), [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md), [ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.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 0238](0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md), [ADR 0239](0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)
- mesh-controller #167 (the planner's rules held by a table and a property test)