Design: building a module, and why the recipe cannot always be a Dockerfile

12-a-module-repository says what a module may build and where it goes. Nothing
said how a build is MODELLED, and the model is the problem: a recipe is implicit,
singular and always a Dockerfile; a toolchain is not modelled at all, arriving as
two build arguments the module hand-writes; a language is not a concept; and an
archive is declared in the manifest and refused by the builder.

The cost is measurable rather than theoretical. Adding a module with its own code
means repeating an incantation - two ARG bases, a specific working directory so
the SDK resolves upward, the compiler invoked by absolute path because the usual
symlink is resolved away when the base is assembled, a second stage, an env var
naming the entrypoints. Most of the catalogue is unconverted, and two conversions
done in one session were each wrong twice with a working example open.

So: recipe becomes explicit with three kinds, and toolchain becomes derived from
a declared language rather than written by every author. A Dockerfile stays, and
stops being compulsory - it is right for software needing a particular base and
wrong for "compile my module's code", which is the same operation every time.

The cost is stated before it is chosen: every language is permanent, and the
contracts are already expressed twice - Go structs and TypeScript types kept in
step by hand. A second language makes that drift. So language-neutral contracts
come first, or the drift gets worse while hiding.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 01:51:42 +02:00
parent b163ed1fcc
commit 5d1e6d0b09
2 changed files with 141 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
---
layer: to-be
status: proposed
code:
- mesh-control cmd/mesh-builder
- mesh-control internal/builder
- mesh-catalog modules/builder
updated: 2026-09-15
decisions:
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
- 02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md
- 02-DECISIONS/0009-modules-and-the-graph.md
---
# Building a module
[`12-a-module-repository`](12-a-module-repository.md) says what a module may build — an image, an
archive, an upstream mirror — and where the result goes. This says **how a build is modelled**, and
why the current model does not fit what a module is.
## The domain, in one sentence
Turning a module's source into artifacts the mesh can pin, publish and deliver — and saying
truthfully what was produced and what it was produced against.
Everything else is somebody else's: *what* to build is the control plane's, *what a build means* is
the catalogue's ([ADR 0072](../../02-DECISIONS/0072-two-graphs-and-the-build-chain.md)), *where an artifact runs* is the
control plane's again. The builder's whole responsibility is the middle.
## The language
| term | is |
|---|---|
| **source** | a repository, a path within it, and a ref — resolved to one commit ([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)) |
| **recipe** | how *one* artifact is produced from that source |
| **toolchain** | what a recipe runs inside — a compiler, a runtime, the SDK |
| **artifact** | what a recipe produced, named by the digest of its content |
| **publication** | putting an artifact where machines can fetch it, by that digest |
| **announcement** | telling the mesh what was built, and every artifact it stood on |
| **build** | one request and its outcome, correlated, recorded whether it worked or not |
## What the model is today, and where it does not fit
**A recipe is implicit, singular, and always a Dockerfile.** `build.artifacts[].from` names one, and
producing anything means writing one. **A toolchain is not modelled at all** — it arrives as two
build arguments the module's own Dockerfile declares and the mesh fills in. **A language is not a
concept.** And **an archive is declared and unbuildable**: the manifest has the kind, the builder
refuses it.
The cost is not theoretical. To add a module that carries its own code today, an author writes a
Dockerfile that: declares two `ARG` bases with no defaults; compiles under a specific working
directory so the SDK resolves upward; invokes the compiler *by absolute path*, because the usual
`node_modules/.bin` entry is a symlink that the base image's own assembly resolves away; copies the
output into a second stage; and sets an environment variable naming the compiled entrypoints. Every
module repeats it. Miss any step and the failure arrives somewhere else — as a crash loop, a
placeholder digest, a module that builds and does nothing.
The evidence that this is too hard is in the catalogue: **most modules are not converted**, and the
two converted during one session were each wrong twice before they were right, against a working
example sitting open in the next window.
**A module is not a container** ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)) — it is
one piece of software and everything that makes it real: its provisioner, its tools, its hooks, its
scheduled steps, its event consumers. A build model whose only output is a container image is
modelling one of ten resource kinds and calling it the module.
## The model
**Recipe becomes explicit, and there is more than one kind.**
| recipe | produces | from |
|---|---|---|
| `image` | an image | a Dockerfile, when the software genuinely needs one |
| `archive` | a bundle, fetched by digest and unpacked | this module's source, compiled and bundled |
| `upstream` | a mirror | somebody else's pinned reference |
**Toolchain becomes explicit, and is derived rather than written.** A module says what it is written
in; the builder knows what that implies. The two base images stop being something an author names
and become something a toolchain *is*.
```
module says: language: typescript
builder knows: compile in the typescript toolchain, bundle, produce an archive
```
A Dockerfile remains available and stops being compulsory. It is the right answer for software that
needs a particular base, and the wrong answer for "compile my module's code", which is the same
operation every time.
**Why an archive and not always an image.** An archive is a content-addressed blob fetched over
plain HTTP and verified by its own digest, so it needs no registry account and no trusted transport
— it verifies itself. A container image is refused by a runtime over plain HTTP as *policy*, which
is why [issue 048](../../04-ISSUES/048-nothing-makes-a-machine-trust-the-mesh-registry/00-report.md)
exists. Modules that are the mesh's own code do not need a container's isolation from the mesh; they
need to run. Third-party software still arrives as an image, because that is how its author ships it.
## The invariants
1. **An artifact is named by the digest of its content.** Not by a tag, not by a path, never by a
placeholder. A recipe that cannot produce a digest has produced nothing.
2. **A build announces exactly what it made and everything it stood on.** The second half is what
makes build edges derived rather than declared ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
3. **A failed build produces nothing and announces nothing.** A partial artifact under a digest the
mesh will later trust is worse than no artifact.
4. **A recipe with nowhere to publish refuses before it builds, not after.** An archive is bytes that
mean nothing until something serves them; producing one with no destination has produced nothing
usable, and saying so beats returning a path no other machine can read.
5. **A build is recorded whether or not it worked**, with everything it was told — the resolved
manifest, the path, what it stood on. A record that keeps only the outcome cannot be replayed to
anything that missed it ([issue 050](../../04-ISSUES/050-the-catalogue-knows-nothing-built-before-it/00-report.md)).
## What this costs, stated before it is chosen
**Every language is permanent.** It needs an SDK — broker client, sealed-credential reading, the
event envelope, tool serving — a toolchain image, and a bundler the builder understands. And the
spine changes rarely but cascades when it does
([ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)): with one language a contract
change is one edit; with four it is four that must land together, and a mesh whose SDKs disagree
about the envelope fails by ignoring messages rather than by failing to compile.
**So the contracts have to stop being expressed twice before they are expressed four times.** They
are already: the manifest, declaration and link shapes exist as Go structs in the control plane and
as TypeScript types in the SDK, kept in step by hand. Nobody has felt it because both live in one
repository. A second *language* makes that drift; a specified envelope and schema that every SDK
implements makes a second language an implementation rather than a translation.
**Order matters, then.** Language-neutral contracts, then a second language. The other way round
makes the drift worse while hiding it.
## How these rules are checked
| rule | checked by |
|---|---|
| A module with its own code needs no Dockerfile | A module declaring only a language builds, and its artifact is pinned to a digest the mesh's registry assigned. |
| An archive is produced and delivered | A module declaring an archive is built, and the machine assigned it has the unpacked files — verified on the machine, not in the build's own output. |
| A Dockerfile still works | A module that declares one builds exactly as before, because software that needs a particular base has not stopped existing. |
| Nothing is announced that was not made | A build made to fail announces nothing, and the catalogue's graph is unchanged afterwards. |
| A build keeps what it was told | A catalogue started after a build asks for what it missed and receives the manifest and the artifacts stood on, not a summary. |
| The toolchain is the mesh's, not the author's | Changing the toolchain makes every module built against it stale, and each names what moved. |
+1
View File
@@ -27,6 +27,7 @@ document is written and this one's status becomes `implemented`.
| [`15-the-agent-session.md`](15-the-agent-session.md) | One mechanism started twice — a node's session and the mesh's | [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md), [ADR 0026](../../02-DECISIONS/0026-the-mesh-has-a-session-of-its-own.md) |
| [`16-module-coverage.md`](16-module-coverage.md) | What a module must be able to say, measured against 127 that exist | [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md), [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) |
| [`17-raising-a-mesh.md`](17-raising-a-mesh.md) | How a mesh comes into existence, and how a machine joins one that exists | [ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md), [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) |
| [`18-building-a-module.md`](18-building-a-module.md) | How a build is modelled, and why a recipe that is always a Dockerfile does not fit what a module is | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md), [ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md), [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
## Not yet written