"control plane" -> controller and "substrate" -> foundation throughout 03-DESIGN, 00-META and the README, with 06-the-control-plane.md and 07-the-substrate.md renamed to 06-the-controller.md and 07-the-foundation.md. The immutable 02-DECISIONS records keep their original wording (and links to them are unchanged) — a term retired here may still appear there, which the glossary explains how to read. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
223 lines
13 KiB
Markdown
223 lines
13 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 controller'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
|
|
controller'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 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 a recipe is
|
|
effectively singular: producing anything *compiled* means writing a Dockerfile.
|
|
|
|
**Archives already work, and that is the corrected half of this.** An earlier draft of this
|
|
document said the builder refused them. It does not: an archive is packed deterministically,
|
|
hashed, published by digest, fetched by the machine and unpacked. Only the *local* builder used at
|
|
genesis refuses one, deliberately — an archive is bytes that mean nothing until something serves
|
|
them, and there is no registry yet.
|
|
|
|
**What an archive cannot do is compile.** `from` names a directory and the directory is packed as
|
|
it stands, so shipping compiled output means compiling somewhere first — which means a Dockerfile,
|
|
which is the burden this is about. The gap is not the artifact kind. It is that **no recipe both
|
|
builds and packs**.
|
|
|
|
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 | a directory, packed as it stands — **today** |
|
|
| `bundle` | the same, fetched and unpacked | this module's source, *compiled* by a toolchain and then packed — **the missing one** |
|
|
| `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 controller 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. |
|
|
|
|
## The whole surface, in one place
|
|
|
|
**A reference, and it is kept true by a test rather than by care.** A table like this goes stale the
|
|
day somebody adds a field, so `mesh-catalog/modules/showcase` is a module that uses all of it, and
|
|
`TestTheShowcaseModuleIsAValidManifest` fails when it stops doing so. Read the module when this
|
|
disagrees with it.
|
|
|
|
### What a module declares
|
|
|
|
| field | is |
|
|
|---|---|
|
|
| `module`, `version`, `slug` | its identity; the slug is the short name generated names are built from |
|
|
| `capabilities` | what a machine must have for this to run there |
|
|
| `provides` | **the shared seat** — several modules may fill one capability and coexist |
|
|
| `claims` | **the exclusive seat** — two modules claiming one thing in a scope cannot both be assigned there |
|
|
| `serves` | what a consumer must know to connect. The mesh fills in the assigned port ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)) |
|
|
| `requires` | what must be provided by something on the same node |
|
|
| `binds` | where the mesh writes what a requirement resolved to |
|
|
| `secrets` | where the mesh seals the credential for a requirement |
|
|
| `own-secrets` | secrets that are the module's own — a superuser, a broker account |
|
|
| `emits` / `consumes` | the event graph: 1:many, broadcast, no credential |
|
|
| `listens` | the port **its own software** uses, and from where. Filtering is computed from these |
|
|
| `contributes` / `receives` | values one module adds to another's configuration, and the other half |
|
|
| `accesses` | operator-owned paths it may use and must not own ([ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md)) |
|
|
| `certificate` | a certificate for a name it serves |
|
|
| `grants` | credentials it must create for its consumers |
|
|
| `filtering` | rules beyond its own ports |
|
|
| `computed` | marks a module the controller generates rather than an author writing |
|
|
| `build.artifacts` | what it produces |
|
|
|
|
### What it builds
|
|
|
|
| kind | is |
|
|
|---|---|
|
|
| `bundle` | its own code, compiled by the toolchain its language implies, then packed |
|
|
| `archive` | a directory, packed as it stands |
|
|
| `image` | built from a Dockerfile — for software that needs a particular base |
|
|
| `upstream` | somebody else's image, mirrored and pinned by a digest this mesh assigned |
|
|
|
|
### What it puts on a machine
|
|
|
|
| resource | is | a module may |
|
|
|---|---|---|
|
|
| `directory` | a directory with a mode and an owner | ✅ |
|
|
| `file` | literal content, with `${bound:…}` and `${secret:…}` filled in | ✅ |
|
|
| `user` | a login | ✅ |
|
|
| `access` | a pre-existing path it may use and must not own | ✅ |
|
|
| `archive` | files fetched by digest and unpacked | ✅ |
|
|
| `package` | a package that must be present | ✅ |
|
|
| `network` | a named container network | ✅ |
|
|
| `container` | an image, in three modes | ✅ |
|
|
| `process` | **its own code**, in three modes | ✅ |
|
|
| `service` | an **existing** unit put into a state | for software shipping its own unit |
|
|
| `action` | a command to run | ❌ **refused** — [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) |
|
|
|
|
**The two at the bottom are the interesting rows.** `action` is refused outright: the link may not
|
|
carry a command, so a module needing something done ships a program that reconciles — which is what
|
|
a run-once `process` is. `service` installs no unit by design, which is right for software that
|
|
ships one and wrong for code the mesh built, which has no unit until the mesh writes it.
|
|
|
|
### How its code runs, and what that code can be
|
|
|
|
| mode | is | | shape | loaded by |
|
|
|---|---|---|---|---|
|
|
| *(default)* | a unit restarted when it exits | | tools | a tool host, over the broker |
|
|
| `run-once` | run to completion; what follows is gated on it | | event consumer | the same host, reacting |
|
|
| `schedule` | a timer; a missed fire happens when the machine returns | | provisioner | invoked when a consumer is granted |
|
|
| | | | a process | the machine's supervisor |
|
|
|
|
**Tools, hooks and consumers are not further modes**, which is the test of whether three is the
|
|
right number: they are loaded by a tool host, and a tool host is a process that stays up.
|