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:
@@ -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. |
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user