Merge pull request 'Issue 280: a rebuild of an unchanged source was read as a new bus' (#153) from issues/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus into main

This commit was merged in pull request #153.
This commit is contained in:
2026-10-06 20:12:06 +00:00
2 changed files with 97 additions and 0 deletions
@@ -143,6 +143,20 @@ machine. So:
waits and the remedy;
- **a rebuild that made the same artifacts from the same manifest is no move.**
> **Progressive insight — 2026-10-06.** This rule assumed that a rebuild changing nothing makes the same
> artifacts. That holds for an archive or a bundle, which the builder packs deterministically. It does
> not hold for an image: every build of an unchanged source makes a new image digest. So a catalogue
> merge that never touched the bus rebuilt it, the new digest read as a new bus build, and every send
> to the control node was refused until a planned bus upgrade
> ([issue 280](../04-ISSUES/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus/00-report.md)).
> The rule said "a rebuild that made the same artifacts from the same manifest". It now also covers
> **a rebuild made from the same source**: the module's tree at the commit, the trees of the contexts
> it read, and its bases and toolchains by digest, hashed by the builder as the build's source
> fingerprint. Such a rebuild is registered with the artifacts of the build it repeats, so no machine
> is sent a new digest. Identical artifacts remain the second way to be no move, and the only way for
> a build the registry decides. The decision stands: a rebuild that changes nothing is no move. Only
> the test for "changes nothing" was wrong.
**The release plan walks what waits.** Whenever builds no gate has seen wait on machines and no plan that
has started walks them, the mesh opens a release plan: every such machine heard from, **one at a time,
the control node last**, each sent everything waiting there and judged by the gate before the next is
@@ -0,0 +1,83 @@
---
status: located
opened: 2026-10-06
located-in: [mesh-controller cmd/mesh-controller, mesh-controller internal/builder, mesh-controller internal/inventory]
fixed-by: mesh-controller PR #99
amended-design:
---
# 280. A rebuild of an unchanged source was read as a new bus
## Symptom
On 2026-10-06 a catalogue merge added the merge check (a script at the repository's root) and changed
the forge module. Nothing under the bus module's directory changed. The merge rebuilt the bus all the
same, and the controller read the rebuild as a new bus build. From then on every send to the control
node was refused until someone ran a planned `bus upgrade`. That was a deadlock. The fix for issue 278,
which narrows what a merge rebuilds, was itself waiting to be sent to the control node. A person took
one bus step by hand to break it.
## Diagnosis
**Two builds of one source counted as two builds.** [ADR 0236](../../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)
treats a rebuild as no move when it made the same artifacts from the same manifest. That works for an
archive or a bundle: the builder packs those deterministically, so one source gives one digest. **An
image is not byte-reproducible.** Every `docker build` of an unchanged source makes a new image
digest. So for every module shipped as an image, the bus among them, a rebuild was always a move. The
bus's guard did the rest: no send reaches the bus's machine while a new bus build waits there.
The rule asked the wrong question. What the mesh needs to know is whether the build was made from
something different. The builder can answer that, because it reads only what it was given. The recipe
and the compiler see the module's own directory and nothing else. Any other repository an artifact
reads is cloned at a ref the manifest names. Every base is handed over by digest. A TypeScript or Go
bundle is compiled in a toolchain image the mesh holds by digest.
**Why the merge rebuilt the bus.** The first guess was the shared-code rule from before issue 278,
still live because the new announcer had not been deployed. That guess is wrong. The merge changed a
file at the repository's root, the merge check script. **A file at the root is shared code under both
rules**: issue 278 lets a directory holding a module claim its own files, and the root is never such a
directory. The controller's `plans` what-if, which saves nothing, shows this. It was given the merge's
files with the announcer's answer (the forge module's directory holds a module). It planned 103
modules. The same files without the root script plan the forge module alone. **The new announcer would
not have narrowed this merge.**
## Fix
- **The builder says what each build was made from**: its source fingerprint, a hash of
- the git tree of the module's directory at the commit built, which covers its manifest, its recipes
and its code;
- the git tree of each other repository an artifact's context is cloned from;
- every base it was handed, by digest only, so the registry address it was copied to does not count;
- for a bundle, the compiler image's digest and the builder's own recipe for that language.
A build that pulls packages from the mesh's package registry at build time gets no fingerprint. That
covers a package artifact, a TypeScript bundle with packages of its own, and an image whose recipe
reads the registry credential. The same source can give a different program there, so such a build
is told apart by its artifacts alone, as before. The controller records the fingerprint with each
build.
- **A build whose source is unchanged is no move.** The controller treats two builds of a module with
one fingerprint as one build. It registers the rebuild at its new commit but with the artifacts of
the build it repeats, which is the oldest build in the unbroken run with that fingerprint. A build
that failed its gate breaks the run. As a result, no machine is ever sent a new digest for a source
nobody changed. A module that stands on it is handed the same base, so it does not move either. The
bus's planned step asks for nothing. A plan whose build matches what every machine running the
module was sent sends nothing and judges nothing. Identical artifacts remain a second way to be no
move.
## Left open
The wide rebuild itself. The builder reads only a module's own directory, so a file at a repository's
root, outside every module's directory, is read by no module built from a subdirectory. The controller
still treats such a file as shared code and rebuilds everything built from the repository. With this
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.
## How it is checked
| Rule | Checked by |
|---|---|
| a fingerprint names what a build was made from | the builder's test: one tree and one base give one fingerprint, even with the base copied to another registry address; a changed tree or a moved base gives another; a build the registry decides, or whose tree was not named, has none |
| an unchanged source keeps its artifacts | the controller's test: an image module rebuilt from an unchanged source with a new digest is registered with the digest the mesh held, at the new commit; modules standing on it are handed that digest; the two builds are one; a changed source registers and moves; a build with no fingerprint is told apart by its artifacts |
| no bus step for an unchanged bus | the controller's test: the bus rebuilt from an unchanged source with a new digest holds no push to its machine, `bus upgrade` has nothing to do and takes no snapshot, and a real change to its source demands the planned step again |
| a plan sends nothing for an unchanged source | the controller's test: a plan's build made from the source every machine runs is marked sent with "no move", with no send and no gate; a changed source is sent to its first machine and gated |
| live | the next catalogue merge that rebuilds the bus without touching its directory demands no bus step; `bus` says every machine runs the build the mesh holds |