Merge pull request 'A module is a repository and a path, and installing is described to its end' (#33) from feat/a-module-is-a-repository-and-a-path into main

This commit was merged in pull request #33.
This commit is contained in:
2026-09-12 16:54:22 +02:00
7 changed files with 471 additions and 2 deletions
+112
View File
@@ -0,0 +1,112 @@
---
topic: building it
status: proposed
date: 2026-09-12
deciders: jochen
reconstructed: false
extends: 0016-the-lab.md
---
# 68. The lab takes requests, one at a time, and runs each from its own copy
## Context
**The lab is exclusive hardware, and today a person holds it.** Raising a scenario takes over
addresses and names on the workstation for as long as it stands, and only one scenario can stand
at a time. So a run is not merely slow — it occupies the machine and the person who started it,
who then waits rather than works.
**Running it in the background against the working copy is worse than waiting.** The obvious fix
is to start a run and carry on editing. But a run reads the working copy as it goes: binaries are
rebuilt from it, manifests are read out of it, and the bed's own code is loaded from it. Edit
while it runs and the result describes a state that never existed — a mixture of what was there
when each file happened to be read. A green result obtained that way is not evidence, and a red
one costs a day to disbelieve.
**Nothing today records what was asked for.** A run is a command line in somebody's terminal. What
commit it exercised, what it was trying to find out, and what it answered all live in scrollback,
which is why the same question gets re-run rather than looked up.
**Most of the parts already exist.** The lab writes a receipt of its last run. The mesh already
carries messages between nodes and can notify a person. The machine already runs work on a
schedule. What is missing is the thing in the middle.
## Considered Options
**1. Leave it as it is — a person drives the lab and waits.** Rejected. It is the loop
[ADR 0010](0010-delivery.md) removed everywhere else, kept here by habit rather than by argument,
and the cost compounds: because a run is expensive to start and blocks the person, fewer are run,
so faults are found later and in larger batches.
**2. Run in the background against the working copy.** Rejected on the reasoning above. The
failure is silent, which is the kind this repository exists to refuse.
**3. Put the lab behind the ordinary build pipeline.** Rejected for now. The pipeline builds
artifacts and does not own a machine that can raise virtual machines; giving it one makes the
pipeline's slowest job the lab's, and couples every push to hardware only one machine has. This
may become right later; it is not the smallest thing that works.
**4. A queue in front of the lab, and an isolated copy behind it.** Chosen.
## Decision
**The lab accepts requests rather than commands.** A request is recorded, queued, and answered.
The person who made it is told when it is answered and does not wait.
**A request names a bed and a commit, and nothing else.** This is the load-bearing restriction. A
request may say *run this bed, at this version of these repositories*. It may not say what to
install, on which machine, or with which settings — because a request that could say those things
would be a second way of installing a mesh, and the whole reason the installer exists is that the
lab already was one ([ADR 0067](0067-genesis-is-a-pivot.md)). The bed decides what is installed;
the request only decides which bed and which version.
**Requests are released one at a time.** The hardware admits one standing scenario, so the queue
enforces what the hardware already requires, rather than leaving it to whoever remembers.
**Every run happens in a copy the lab owns.** The lab checks the requested commit out into its own
path and builds and runs from there. A working copy is never read by a run. This is what makes the
queue safe to use while work continues, and without it the rest of this record is not worth
having.
**The lab is reached through tools, not only a command line.** A command line is available only
to whoever is sitting at the machine, which is the constraint this record exists to remove. The
lab answers three questions to anything that can reach the mesh — *what is standing now*, *what is
queued or running*, and *what did this request answer* — and accepts a request and a cancellation.
An agent can therefore start a run, stop attending to it, and come back; and somebody who did not
start a run can still see it, which is the difference between a shared lab and a private one.
**The restriction holds at every door.** A tool submits a bed and a commit, exactly as a command
line does. A tool that could name a module, a node or a setting would reintroduce the second
installer through a different entrance, and the entrance is not what made it dangerous.
**Every run leaves a record that outlives the terminal**: what was asked, which commit, when it
ran, what it answered, and where its output went. A question already answered is looked up rather
than re-run.
## Consequences
Work continues while the lab runs, which is the point. A second session may edit freely, because
nothing it edits is what the lab is reading.
A request is reproducible by construction: it names a commit, so the same request can be asked
again and compared. Today two runs of "the same thing" are only as alike as the tree happened to be.
The lab gains a second copy of every repository it exercises, costing disk and needing to be kept
from drifting into a place people edit by hand.
Anything that can reach the mesh can now see what the lab is doing, including an agent working on
something else. That is the intended gain and also the obvious hazard: a thing that is easy to ask
is easy to ask too often, and the hardware still admits one scenario at a time.
The queue becomes a thing that can fail — stuck, backed up, or lost — and a queue nobody watches
is worse than no queue, because it absorbs requests silently.
## How this is checked
| Rule | Checked by |
|---|---|
| A run never reads a working copy | The runner is given a path it owns and no other; a run started while a working copy is deliberately dirtied produces a result matching the commit, not the edits. |
| One scenario stands at a time | A second request submitted while one runs is observed to wait, not to raise. |
| A request cannot say what to install | The request format admits a bed and a commit only. A request naming a module, a node or a setting is refused, and the refusal is exercised. |
| A request is answered | Every queued request reaches a terminal state with a record. A request that vanishes is a failure of the queue, not a quiet nothing. |
| The lab can be asked from elsewhere | What is standing is asked from a session that did not raise it, and the answer matches the machine. A lab that only answers its own caller has not left the terminal. |
@@ -0,0 +1,89 @@
---
topic: building it
status: accepted
date: 2026-09-12
deciders: jochen
reconstructed: false
extends: 0009-modules-and-the-graph.md
---
# 69. A module is a repository and a path within it
## Context
**The builder clones one repository and reads `module.json` at its root.** The to-be design says
so in as many words — *"one file at the root"* — and the code implements it: clone, read the root
manifest, build what it declares.
**Nothing that exists is shaped that way.** The catalogue holds sixty-seven modules, each in its
own directory, and has no manifest at its root. None of the five code repositories has one either.
So today the builder cannot be asked to build any module that exists: pointed at the catalogue it
finds no manifest, and pointed at a module's source it finds no manifest.
**The system being replaced already works the other way**, and has for years: a monorepo with one
directory per piece of software, and the coordinator builds a module from a repository and a path
inside it. The root-only assumption is not a simplification of that — it is a different model that
was never reconciled with it.
**And it splits what a build needs into two places.** The control plane's manifest sits in the
catalogue; the source it describes sits in the control plane's own repository. A build must read
one tree, so under the root-only model neither location can be built from.
## Considered Options
**1. One repository per module.** Rejected. Sixty-seven repositories for sixty-seven modules, most
of which are a single manifest naming a public image, and every one needing its own creation,
permissions and lifecycle. It also contradicts [ADR 0015](0015-applications-live-in-their-own-repository.md),
which put *applications* in their own repositories precisely because modules do not need one.
**2. Keep manifests in the catalogue and source elsewhere, and have a build fetch both.** Rejected.
A build would clone two trees whose versions can disagree, so "what commit is this module?" stops
having one answer — and that question is the whole basis of knowing when to rebuild.
**3. A module is a repository and a path within it.** Chosen. It is what the current system does,
what the catalogue already looks like, and it keeps a module's description beside the thing it
describes.
## Decision
**A module is named by a repository and a path within it.** The path holds `module.json`, and
everything that manifest declares is produced from that path. A module whose path is the root is
the ordinary case of this, not a separate one.
**A module's manifest lives beside its source.** Where a module has code, its directory holds both,
so one commit answers "what is this module, and what is it made of". Where a module has no source —
a manifest naming a public image — the directory holds only the manifest, and there is nothing to
build.
**This moves the core modules.** The control plane and the builder are built from the control
plane's repository, so their manifests belong in that repository at their own paths, not in the
catalogue. The catalogue keeps the modules whose source it holds, and the modules that are only a
manifest.
**One commit, one module version.** Because a module is one path in one repository, the commit that
built it identifies it exactly, and "the source has moved ahead of what the mesh holds" stays a
question with a yes or no answer.
## Consequences
The builder gains a path alongside the repository and the ref. A build is `repository, path, ref`,
and the manifest it returns is the module the mesh records.
The catalogue stops being the place every manifest lives, and becomes the place manifests live
*when their module has no other home*. That is a smaller claim than it sounds: most of the
sixty-seven stay exactly where they are.
Two repositories change shape — the control plane's gains manifests for the modules built from it.
Nothing else moves.
A repository can hold modules that are built and modules that are not, and no rule distinguishes
them beyond whether their manifest declares anything to build.
## How this is checked
| Rule | Checked by |
|---|---|
| A module is buildable from its repository and path | The builder is asked for a module by repository and path, and returns a manifest whose artifacts are pinned to digests the mesh's registry assigned. |
| A manifest sits beside what it describes | A module declaring something to build, whose path holds no source to build it from, is refused at build time rather than producing an empty result. |
| One commit identifies one module | Two builds of the same repository, path and commit produce the same digests. |
| The core modules are built like any other | The control plane is rebuilt from its own repository and path, and the running mesh is upgraded to it — the same path an ordinary module takes. |
+2
View File
@@ -100,6 +100,8 @@ python3 00-META/checks/index.py fail if stale
- **0036** — [Bootstrap ends at a usable mesh, and the first credential comes from a person](0036-bootstrap-ends-at-a-usable-mesh.md)
- **0066** — [Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them](0066-public-routing-is-name-agnostic.md)
- **0067** — [Genesis is a pivot: a temporary control plane installs the registry that makes it permanent](0067-genesis-is-a-pivot.md)
- **0068** — [The lab takes requests, one at a time, and runs each from its own copy](0068-the-lab-takes-requests.md) *(proposed)*
- **0069** — [A module is a repository and a path within it](0069-a-module-is-a-repository-and-a-path.md)
### What runs on them, and how it gets there
+55 -2
View File
@@ -2,12 +2,14 @@
layer: to-be
status: in-progress
code:
- mesh-catalog modules/builder
- mesh-control internal/builder
- mesh-control internal/catalogue/build.go
- mesh-control internal/inventory/secrets.go
- mesh-control cmd/mesh-builder
updated: 2026-08-31
updated: 2026-09-12
decisions:
- 02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0010-delivery.md
- 02-DECISIONS/0005-the-node-host.md
@@ -39,13 +41,25 @@ resolution, and assigning it brings both. So the module count does not return, b
that made it return — *a module is expensive, so put several things in one* — is gone. A module
here is cheap: a manifest and, usually, nothing else.
## One file at the root
## One file, at the module's own path
`module.json`, and a convention somebody can look for beats a setting somebody has to find. It
says what the module is, what it provides and requires, what it claims, what capabilities it
needs, what it puts on a machine — and, if anything must be produced from the source, what to
build.
**A module is a repository and a path within it** ([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)).
The manifest sits at that path, beside the source it describes, and everything it declares is
produced from there. A module whose path is the repository root is the ordinary case of this and
not a separate shape.
*Corrected 2026-09-12. This section previously said the manifest was at the root, full stop, which
made a repository hold exactly one module. Nothing that exists is shaped that way: the catalogue
holds sixty-seven modules in their own directories, the system being replaced has always built a
module from a repository and a path, and the root-only reading left every existing module
unbuildable — pointed at the catalogue the builder finds no manifest, pointed at a module's source
it finds no manifest either.*
## The manifest in the repository is not the manifest the mesh holds
A resource names an artifact:
@@ -345,3 +359,42 @@ first copy comes from outside, exactly once, and every copy after it is the mesh
machine across the private network, because a mesh-scoped provision that only answers locally is
not one. A container that is running is not a registry that replies, and this project has paid for
that distinction once already.*
## The three the loop cannot build, and there are only three
*2026-09-12. Written down because it keeps being rediscovered as if it were new, once per
component. It is one rule, it has three instances, and the list is closed.*
**Anything the build loop needs in order to run cannot be delivered by the build loop.** It arrives
from outside exactly once, and from then on it is an ordinary module, upgraded like one. What
"from outside" means is *not* the same for all three, and saying so matters, because two of them
have a route and one does not:
| What | Why it cannot come through the loop | How it arrives |
|---|---|---|
| The control plane | It is what installs modules. Nothing can install it before it runs. | **Carried inside the installer** and published once there is a registry ([ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md)) |
| The registry | It *is* where artifacts are delivered from. A store cannot be delivered through itself. | **Pulled from the public internet** — its image is an ordinary public one, never built ([`04-ISSUES/029`](../../04-ISSUES/029-the-artifact-store-cannot-be-delivered-by-the-artifact-store/00-report.md)) |
| The builder | It is what builds. Nothing builds it before it runs. | **Nothing yet.** Not carried, not public. See below. |
**The builder has no route, and this is the open one.** It is built from the control plane's
repository, so it cannot be pulled from the public internet like the registry; and the installer
carries one image only, the control plane's. So a mesh raised by the installer today has no builder
and no way to obtain one, which means it cannot build the catalogue, which means every module
waiting on a digest keeps waiting. Whatever answers this — the installer carrying a second image,
the control plane's own build producing both, or the first builder being fetched some other way —
is the last thing between a raised mesh and a self-upgrading one.
**There is no fourth.** Everything else the mesh runs is either upstream — a third-party image
pulled by digest — or built by the builder from a repository and published to the registry. So the
question "how does *this* one get here first?" has an answer for every module without asking it
again: if it is not one of the three above, it comes through the loop.
**A carried artifact is not a differently-pinned artifact.** Once published it is named by a digest
the mesh's registry assigned, exactly like everything the builder produces. A reader cannot tell
from a running mesh which of its images were carried, and that is the point: carrying is how the
first copy arrives, not what it permanently is.
*Checked by the thing already checked at genesis: after installing, the running control plane is
pinned to a digest the mesh's own registry assigned, and not to the id of the image the installer
carried. The same check applies to the builder and to the registry, and it is the same check —
an image id where a registry digest belongs means the pivot did not finish.*
+159
View File
@@ -0,0 +1,159 @@
---
layer: to-be
status: in-progress
code:
- mesh-host cmd/mesh-bootstrap
- mesh-host internal/bootstrap
- mesh-lab test/integration/whole-mesh-full.test.ts
updated: 2026-09-12
decisions:
- 02-DECISIONS/0067-genesis-is-a-pivot.md
- 02-DECISIONS/0006-the-substrate-and-the-control-plane.md
- 02-DECISIONS/0005-the-node-host.md
- 02-DECISIONS/0010-delivery.md
---
# Raising a mesh
How a mesh comes into existence on machines that have none, and how a machine joins one that
already exists. This is the procedure an operator runs. It is not a description of the lab, and
the lab does not have one of its own.
## Two moments, and only two
A mesh is raised once and joined many times, and the two are not variations of each other.
**Genesis** happens on one machine, when there is no mesh. Nothing can be asked, nothing can be
granted, and nothing has been published anywhere. It is the only moment at which the ordinary
rules cannot all hold at once, and it is resolved by a pivot
([ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md)).
**Joining** happens on every machine after the first, and it is ordinary. A mesh exists, so it can
be asked for a token and told what the machine should be. Joining installs the host and nothing
else: no temporary anything, no substrate raised by hand, no registry.
Confusing the two is what produced a procedure that only ever worked in a fixture. A bed that
raises four machines the same way has not tested genesis at all — it has tested joining, four
times, with the first one hand-fed.
## Genesis
The installer is a single program carrying the control plane's image inside it. That is what makes
genesis possible without a network to fetch from and without a registry to name: the image is
present because the installer is present.
It proceeds in one direction, and every step is safe to run again.
**First it refuses to start if the machine is not ready.** A container runtime, the ability to
write where it must write, the host binary where it expects it. A machine that is not ready is told
what is missing rather than half-changed.
**Then it loads the carried image and describes what the machine will become.** The image is named
by the digest of its own configuration — content-addressed and unforgeable, and requiring nothing
to have served it. This is legal precisely where nothing could have served one, and nowhere else.
**Then it raises the substrate and a temporary control plane, and waits for that control plane to
answer.** At this point the machine is a mesh of one node with nothing joined to it.
**Then the machine joins the mesh it is itself running.** It enrols, and an agent runs on it. Being
heard from once is not the same as an agent running, and the installer checks both, because
enrolling is itself the thing that makes a mesh hear from a machine.
**Then it installs a registry**, so the mesh has somewhere to keep its own images.
**Then it publishes the control plane's image to that registry**, which is the moment the image
first receives a digest assigned by something other than itself. This is the carrying step, and it
is the same step for all three things the build loop cannot produce for itself — the control plane,
the registry, and the builder. The rule and its closed list are in
[`12-a-module-repository`](12-a-module-repository.md#the-three-the-loop-cannot-build-and-there-are-only-three).
**Then it installs the control plane again, as an ordinary module pinned to that digest, and drops
the temporary one.** The pivot is complete: what raised the mesh is gone, and what runs is a module
like any other. From here the mesh can build and roll out its own upgrades, including to the thing
that runs it.
## After the pivot, and still part of installing
Genesis ends with a mesh of one that runs, and that is not the same as a mesh that works. What it
has is a control plane, a store, a queue and a registry. What it cannot yet do is **produce
anything** — and almost every module in the catalogue is waiting to be produced, because a manifest
names what its artifacts are and nothing has made them.
So installing continues:
**The builder arrives.** It is a module like any other and is assigned to a machine like any other,
but it cannot be built by the thing it is — see
[`12-a-module-repository`](12-a-module-repository.md#the-three-the-loop-cannot-build-and-there-are-only-three).
**How it arrives is unsettled**, and it is the one gap that stops everything after this paragraph
from being possible on a machine nobody is sitting at.
**The core modules are built.** Each is named by a repository and a path
([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)), and the builder is
asked for each in turn: it clones, reads the manifest at that path, produces what it declares,
publishes each artifact into the mesh's own registry, and hands back the module with its artifacts
pinned and the commit recorded. The mesh records that, and from then on the module is described by
something it made rather than by a placeholder.
**The control plane is built like the rest.** It was carried in and published once, which got the
mesh running; building it from its own repository and path is what makes it upgradeable. The first
time that happens is the moment the mesh stops depending on the installer for anything.
**And then the catalogue.** Every module with source of its own is built the same way. Until this
has happened a mesh can install only what is public or carried, which is the substrate and little
else.
Only after all of that is the ordinary loop available: change a module's source, the mesh notices
its own copy is older than the source, rebuilds it, and rolls it out. That loop is what makes
moving services across one at a time possible, so it is part of installing rather than something
that comes later.
What remains after *that* belongs to somebody else: adding machines, and deciding what they run.
## Joining
A machine joins with the host binary and a token. It does not raise a substrate, does not install a
registry, and is never enrolled twice. The mesh already knows how to tell a machine what to be;
joining is the point at which a machine starts listening.
## Where the line falls
The installer owns everything that is the mesh's own. The lab owns only what is the lab's: raising
virtual machines, giving them addresses that resolve nowhere, and injecting faults.
**The lab runs the installer. It does not describe installation.** This is the whole point. A
second description kept in step with the first is the arrangement that already failed — the fixture
invented a registry that exists in no production, and hid two separate faults for as long as it
existed. Anything the lab must do that the installer does not is either a lab concern or a hole in
the installer, and saying which is a decision, not a convenience.
## What is not yet true
Stated plainly, because a document that implies otherwise is worse than none.
**The installer is not packaged.** It builds from source. An operator raising a first machine still
needs a toolchain and a working tree, which is most of the burden the installer exists to remove.
**A mesh cannot say how it was raised.** Nothing afterwards can contradict a claim that a machine
was brought up the supported way, so the rule that it must be is, today, unenforced.
**The builder has no way to arrive.** It cannot be pulled from the public internet, because the
mesh builds it; and the installer carries one image only. So the paragraphs above describing the
core modules being built are, today, describing something that cannot start.
**A machine has no account for a registry that asks for one.** The mesh grants a consumer a
credential for a database; it does not yet do so for the store its own images live in. Genesis
avoids the question by carrying the image it needs, which makes this a joining problem and a
pulling problem, not a genesis one.
## How these rules are checked
| Rule | Checked by |
|---|---|
| Genesis works on a machine that is not the lab | The bed raises its first machine by running the installer, not around it. A bed that stops doing so fails its own acceptance check. |
| The other machines join, and are not re-raised | The bed gives them the host binary and a token only. A second enrolment of the first machine is a failure, not a no-op. |
| Every step may be run again | The installer is re-run against a raised machine and must change nothing and report why. |
| An image is named exactly | A machine refuses a bundle naming an image by tag. The refusal is exercised, not assumed. |
| The installer is what installed this | **Nothing.** See above. |
| The builder can arrive on a fresh mesh | **Nothing.** There is no route for it yet. |
| A core module is built rather than only carried | The control plane is rebuilt from its own repository and path, and the running mesh is upgraded to the result — the same path any module takes. |
| Installing produced a mesh that can produce | After installing, a module with source of its own is asked for and comes back pinned to a digest this mesh's registry assigned, not to a placeholder. |
+1
View File
@@ -26,6 +26,7 @@ document is written and this one's status becomes `implemented`.
| [`14-model-access.md`](14-model-access.md) | Model access as a provision, and what a licence is bound to | [ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md), [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
| [`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) |
## Not yet written
@@ -0,0 +1,53 @@
---
status: resolved
opened: 2026-09-12
resolved: 2026-09-12
located-in: []
fixed-by: nothing — the capability already existed and the wrong verb was used
amended-design:
---
# 043 — A module cannot be given an account for the mesh's own queues
**Withdrawn the day it was opened. The premise was wrong.** Kept rather than deleted, because the
mistake is repeatable and the reason is worth reading.
## What was claimed
That a build machine could not be given access to the mesh's build queue, because a broker account
is scoped to what a module `emits` and `consumes`, and the build queue is not a module event. The
evidence was a builder that authenticated and was then refused:
```
ACCESS_REFUSED - User 'anchor-builder' doesn't have permissions to queue 'builds'
```
## Why it was wrong
**The capability exists and is reachable from the command line.** There are two verbs, and they
create different things:
| Verb | Creates | Scoped to |
|---|---|---|
| `module issue <module> --node <n>` | a module's account | what that module emits and consumes |
| `builder issue <name> [--node <n>]` | a build machine's account | the build queue, and the mesh exchange |
The refusal was produced by using the first for a job the second exists to do. Running
`builder issue` and pushing produced a builder that starts and takes work — no change to any
manifest, any code, or the account mechanism.
## The part worth keeping
**The wrong verb succeeds, and says so.** `module issue` reported *"broker account created, scoped
to what it emits and consumes"* for a module that emits and consumes nothing, producing an account
that authenticates and can do nothing. The failure then appears one layer away, in the module's own
log, as a permissions error against a queue — which reads like a missing capability rather than a
misused command.
That is a small, real sharp edge, and it is the whole of what this issue found. Whether it is worth
anything — a refusal when a module with no events asks for an account, or a note in the builder
module pointing at the verb that fits it — is a judgement, not a defect.
**And a lesson that is not about the mesh:** the capability was three lines away in the same file as
the code being read, under a name that says exactly what it does. The issue was written before
looking for it.