diff --git a/02-DECISIONS/0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md b/02-DECISIONS/0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md new file mode 100644 index 0000000..94acc9b --- /dev/null +++ b/02-DECISIONS/0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md @@ -0,0 +1,112 @@ +--- +topic: the tiers +status: accepted +date: 2026-08-31 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0005-the-node-host.md +--- + +# 29. A network is a shape, because an action cannot be undone + +## Context + +**A module of several containers has no way to let them reach each other by name.** A container +declaration carries a `network` field, and it only ever *joins* one that already exists — it was +added so the control plane could reach the store and the broker on the machine it was raised on. +Nothing in the vocabulary **creates** a network. + +Without one, containers on a machine share the runtime's default bridge, which gives addresses and +no name resolution between them. So a module that is several containers can only be written by +publishing ports onto the machine and pointing its own parts at the host — which puts a module's +private wiring on the machine's own address space, where anything else on the machine can reach it +and any other module can collide with it. + +**This is the gap a mail system meets and nothing else so far does** +([`00-work-breakdown.md`](../03-DESIGN/01-to-be/00-work-breakdown.md) 3.3). It is being taken now +rather than then, because 3.3 is the task most likely to send work back into the declaration +language and the least useful place to discover it. + +**Adding a shape is not a small change, and the host says so** — the vocabulary is asserted +against a stated number, with the reason written into the failure: *every addition widens what a +compromised control plane can express, so a change here is a decision.* The host applies what it +is told; the only bound on a hostile control plane is what the language can say +([ADR 0004](0004-a-node-and-how-it-joins.md)). + +## Considered Options + +1. **An `action` that creates the network.** The vocabulary already has one, the bundle already + uses seven of them, and `docker network create` with a `verify` is exactly the shape an action + takes. Nothing would need adding. **Rejected**, on removal: + + > An action has no footprint the host can undo — it ran, and whatever it did belongs to + > whatever it acted on. + + A network made this way **leaks when the module is unassigned**, and the mesh cannot tell: the + record says an action ran, and there is nothing to reverse. Unassigning a module would leave a + network behind on every machine it was ever on, and the only way to find them would be to go + and look. *A resource the mesh can create and never clean up is one it should not create.* + + There is a second reason, and it is the one that generalises: an action is opaque. **The mesh + cannot tell what an action did**, so a network created by one is not a thing the mesh knows + about — it cannot be reported, counted, or reasoned about, and a module could not require one. + +2. **Publish ports on the machine instead.** No new shape, and it works today. **Rejected.** It + makes a module's internal wiring part of the machine's address space: two modules that each + want a database on a fixed port collide, and anything else on the machine can reach what was + meant to be private. It also makes the module's manifest depend on what else is installed, + which is the thing provisioning exists to remove. + +3. **`network` as a ninth shape.** **Adopted.** + +## Decision + +**`network` joins the vocabulary, and the vocabulary is nine shapes.** + +``` +{"id": "internal", "type": "network", "name": "mail"} +``` + +**A name and nothing else.** Not a driver, a subnet, an address range or a gateway: every one of +those is a thing a module would have to know about the machine it lands on, and a module that +names a subnet is a module that collides with whatever else chose the same one. The runtime picks; +the mesh names. + +**It is created if absent and removed when no longer declared** — an ordinary shape, with the same +lifecycle as a directory. That is the whole reason it is a shape. + +**Declared before the containers that join it.** Resources are applied in the order the module +wrote them, and orphans are removed in **reverse** — so a network written first is created first +and removed last, after the containers attached to it are gone. This is not a new rule; it is the +existing one, and it happens to be exactly right here. A network written *after* its containers +would fail to remove while they still hold it, and that failure is reported rather than silent. + +**What it does not do:** it does not reach across machines. A network is one machine's, like +everything else the host applies. Modules on different machines reach each other over the private +network the mesh already provides ([ADR 0007](0007-connectivity.md)), and a shape that tried to +span machines would be a second overlay with a worse contract. + +## Consequences + +**The vocabulary is nine, and the count moves with a record.** The test that asserts it names this +one, so the next person to change it finds the argument rather than a number to edit. + +**A compromised control plane can now create and destroy networks on a machine.** Stated plainly +because that is the cost, and the bound is the point: it can create a named network and remove +one, and it can do neither to anything it did not declare. It cannot inspect, attach to, or +reroute what is already there — those would be different shapes, and are not being added. + +**A multi-container module becomes expressible**, which unblocks 3.3 and, less obviously, makes +several smaller modules simpler: anything that is a service plus a sidecar currently has to +publish a port to talk to itself. + +**Nothing is required to use it.** A module of one container declares no network and joins none, +exactly as now. The substrate keeps using `host`, which is a runtime-provided network and not one +the mesh creates. + +## References + +- [ADR 0005](0005-the-node-host.md) — the host's vocabulary, and why each shape is a decision +- [ADR 0004](0004-a-node-and-how-it-joins.md) — what may be pushed is bounded by form, not by trust +- [`03-DESIGN/01-to-be/00-work-breakdown.md`](../03-DESIGN/01-to-be/00-work-breakdown.md) — 1.3, + and the mail system at 3.3 that this is for diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 889c264..6302c44 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -93,6 +93,7 @@ python3 00-META/checks/index.py fail if stale - **0007** — [Connectivity](0007-connectivity.md) - **0008** — [A context owns its store, exclusively](0008-a-context-owns-its-store.md) - **0028** — [The substrate supplies the control plane and nothing else](0028-the-substrate-supplies-the-control-plane-and-nothing-else.md) +- **0029** — [A network is a shape, because an action cannot be undone](0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md) ### What runs on them, and how it gets there diff --git a/03-DESIGN/01-to-be/00-work-breakdown.md b/03-DESIGN/01-to-be/00-work-breakdown.md index f6700f6..c95445d 100644 --- a/03-DESIGN/01-to-be/00-work-breakdown.md +++ b/03-DESIGN/01-to-be/00-work-breakdown.md @@ -59,7 +59,7 @@ Found by taking real modules and asking what they would require. Each is a gap i |---|---|---| | ~~1.1~~ | ~~An **object-store provision**~~ — **done 2026-08-31**, and it needed no change to the mesh: see below | seven assertions against a real store | | ~~1.2~~ | ~~**A session as a consumer of a licence**~~ — **done 2026-08-31**, and it also needed no change: see below | two sessions on one machine, different licences, each its own key | -| 1.3 | A **network** shape, and ordering within a module | a module of several containers reaches itself, and one that must start after another does | +| ~~1.3~~ | ~~A **network** shape, and ordering within a module~~ — **done 2026-08-31** | the shape is created and removed; ordering was already there, and is now asserted | | 1.4 | **Public certificate issuance** | a name reachable from outside is served with a certificate from a public authority, obtained against a **staging** endpoint unless told otherwise ([`04-ISSUES/004`](../../04-ISSUES/004-certificate-issuance-targets-production/00-report.md)) | **1.3 and 1.4 block later ones** and are listed now so they are not met as surprises. 1.3 is what @@ -76,7 +76,21 @@ already names them apart, and nothing needed adding. not it*, which is true of a **worker** — many run on one machine from one module — and not true of a session, of which there is one per node and one for the mesh. -**Two tasks in a row that were already possible.** Both were written from the design rather than +### 1.3, and the first one that needed building + +**Ordering was already there** — the apply loop sorts nothing, so a module says *this before that* +by writing it first. Untested until now, and the kind of property a later change breaks silently. +Worth separating from readiness: a container started is not a container ready, and nothing waits. +What needs something *usable* retries, which is what both provisioners do and is the better answer +anyway, because a dependency can restart long after everything was applied. + +**The network was a real gap, and the first thing in Phase 1 that needed a decision.** Adding a +shape widens what a compromised control plane can express, so +[ADR 0029](../../02-DECISIONS/0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md) +records why this one is worth it: an `action` could create a network and **nothing could ever +remove it**, because an action leaves no footprint the host can undo. The vocabulary is nine. + +**Three tasks in a row that were already possible.** Both were written from the design rather than from the code, which is the review's finding arriving in the plan: *a claim here is counted, not reasoned.* The remaining Phase 1 items should be checked against the code before being started, not after. diff --git a/03-DESIGN/01-to-be/05-the-node-host.md b/03-DESIGN/01-to-be/05-the-node-host.md index 8efd176..c3c7f1f 100644 --- a/03-DESIGN/01-to-be/05-the-node-host.md +++ b/03-DESIGN/01-to-be/05-the-node-host.md @@ -332,13 +332,15 @@ reported to be distinguishable from one that reported an empty list.* *Written 2026-08-30. Every addition widens what a compromised control plane can express, so the count is asserted by a test and a change to it is a decision rather than a convenience.* -Six shapes raised the substrate. Two more exist because most of what a person installs is not a +Four shapes raise the substrate. Five more exist because most of what a person installs is not a service: | | why | |---|---| +| **file**, **directory** | the substrate needs neither, and almost everything else does | | **user** | a shell, a terminal, a chat client, a desktop are a package plus configuration **in somebody's home**. A mesh with no user owns `/etc` and nothing anybody looks at | | **archive** | a theme is hundreds of files. Inlining them makes every declaration enormous and rewrites all of them when one changes | +| **network** | a module of several containers has to let them reach each other by name, and doing it with an action would create something nothing could ever remove ([ADR 0029](../../02-DECISIONS/0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md)) | And `file` gained two fields: `bytes`, because a wallpaper is not a string, and `owner`, because a file in a home belongs to somebody. diff --git a/03-DESIGN/01-to-be/07-the-substrate.md b/03-DESIGN/01-to-be/07-the-substrate.md index 02cd451..6ccf9cc 100644 --- a/03-DESIGN/01-to-be/07-the-substrate.md +++ b/03-DESIGN/01-to-be/07-the-substrate.md @@ -194,7 +194,7 @@ So the bootstrap uses four shapes: **package**, **container**, **service** and * *counted from `substrate-first-node.lock`, which is the only bundle there is*. It had said six, adding `file` and `directory`, which this bootstrap never asks for. -All four are built, as are the host's other four +All four are built, as are the host's other five ([`05-the-node-host.md`](05-the-node-host.md) stage 2), so nothing in this bootstrap is blocked on the host any longer — which is the claim that mattered, and it was true either way.