diff --git a/03-DESIGN/01-to-be/18-building-a-module.md b/03-DESIGN/01-to-be/18-building-a-module.md new file mode 100644 index 0000000..8b3444a --- /dev/null +++ b/03-DESIGN/01-to-be/18-building-a-module.md @@ -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. | diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index c84d6b5..328f0f5 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -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