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
141 lines
8.3 KiB
Markdown
141 lines
8.3 KiB
Markdown
---
|
|
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. |
|