--- layer: to-be status: proposed code: - mesh-controller cmd/mesh-builder - mesh-controller internal/builder - mesh-catalog modules/builder updated: 2026-09-25 decisions: - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0097-a-vendor-image-is-a-declared-build-input.md - 02-DECISIONS/0096-an-upstream-image-is-copied-between-registries.md - 02-DECISIONS/0091-a-mount-is-declared-three-ways.md - 02-DECISIONS/0087-a-seeded-file-is-created-once.md - 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 - 02-DECISIONS/0072-two-graphs-and-the-build-chain.md - 02-DECISIONS/0076-the-sdk-is-a-published-package.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)). The repository is either on the forge holding the `git` seat, recorded by its path there and cloned from wherever that forge runs at build time, or external, recorded and cloned exactly as given ([ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.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 | **A container mounts only what the manifest declares** ([ADR 0091](../../02-DECISIONS/0091-a-mount-is-declared-three-ways.md)). A bind mount the module never declared is created by the container runtime as root, so the module's owner and mode never reach its data and the rule that keeps data when a module goes away does not cover it. A path is declared in one of three ways, for the three things a path can be: the module's own (a directory or file resource, or where a secret, grant or contribution lands), the operator's (an `accesses` entry), or the machine's (a facility a declared capability grants — `container-runtime` grants its socket). *How it is checked:* the parser refuses an undeclared mount naming the path and the three remedies, and a test parses every manifest in the catalogue beside the checkout. ### 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 | **An upstream image is copied between registries, never through a machine's image store** ([ADR 0096](../../02-DECISIONS/0096-an-upstream-image-is-copied-between-registries.md)). A published image is an index over several architectures, and a runtime's store refuses to push one platform out of an index it pulled. The builder reads the index and every manifest it names over the registry API, moves each blob by digest into the mesh's registry, puts the manifests and then the index under the module's repository, and pins the index — the whole image, so what a machine fetches is the one for its own architecture. Public images are read with the anonymous token a registry hands out on challenge; a private upstream is refused. *How it is checked:* a test copies an index over two platforms from a fake registry behind a bearer challenge into a fake mesh registry and asserts every blob arrived once, the manifests and index under their digests, and nothing uploaded on a second copy. **A vendor image is a declared build input** ([ADR 0097](../../02-DECISIONS/0097-a-vendor-image-is-a-declared-build-input.md)). A build's `on` entry is a module's artifact or an image published elsewhere, pinned by digest, read from one build argument; the image is copied into the mesh's registry before the build and the recipe is handed the copy. A recipe whose `COPY --from` names a registry image the manifest did not declare is refused before the build, naming it and the remedy, and so is an undeclared `FROM`: the mesh's own images declare the bases they start from. *How it is checked:* builder tests on a declared and an unpinned vendor image, and a recipe test on what counts as a copy and what as a base. ### 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) | A `file` may also say `create-once`: written when absent, left alone when present, reported as kept — a seed a program then owns ([ADR 0087](../../02-DECISIONS/0087-a-seeded-file-is-created-once.md)). Checked by the host's apply tests and by the vault bed, which grows into one and pushes again. **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.