Compare commits

...
Author SHA1 Message Date
jschoubben 862253518f Issues 143 and 144: the found firewall is neither retired nor all of it
Both found by converging the control node — the first machine with a firewall to
flip, since the two before it had none.

143: the preview and the flip both say the found firewall is disabled. The node
reported converged, 372 resources applied, nothing failed, and ufw is still
enabled and active. A converged node's declaration carries no resource that would
disable it; the sentence is printed by the command and nothing implements it.

144: ufw was never what filtered the traffic that mattered there. Fifty forwarded
openings converged through it had matched zero packets, while a chain the
predecessor installed in the container runtime's pre-accept hook did the work —
in memory only, recreated by nothing. The mesh's filter now covers that path, so
the machine no longer depends on it, but the chain remains and is the only thing
refusing the bus and the registry, which the design requires reachable from
anywhere so a machine can enrol before it has a private address.
2026-09-29 01:36:08 +02:00
mesh-admin 51ef3eb7e2 Merge pull request 'ADR 0142: the mesh delivers its own components as binaries' (#175) from decision/0142-mesh-delivers-its-own-components into main 2026-09-28 22:51:44 +00:00
jschoubben 346e613995 ADR 0142: the mesh delivers its own components as binaries, not container images
Measured: the host is a binary somebody copied to four machines, owned by no
package and built by nothing, while the controller, catalogue, builder and vault
are container images publishing no ports at all. Same language, same project,
same kind of work, delivered two ways — and the difference is not a judgement
about either, it is that images are the only delivery that works.

What it costs: genesis must raise a container runtime before the control plane
can exist; updating the control plane goes through a registry the control plane
runs; a host change cannot be rolled out at all; and compiling the language the
mesh is written in is not a capability of the builder, so the controller is built
from a hand-written Dockerfile — the incantation the bundle toolchain exists to
abolish.

Third-party software stays a container: the store, the registry, the broker are
somebody else's build. The container runtime stays on the machine for modules.
What changes is that the control plane no longer needs it to exist.

The receiving half is already built and tested (ADR 0141). Staged: compile Go, an
artifact names its target, deliver a binary, the host first, then the rest, genesis
last.
2026-09-29 00:51:42 +02:00
mesh-admin 25ca9898d5 Merge pull request 'ADR 0141: a progressive insight on what delivery costs' (#174) from decision/0141-progressive-insight-on-delivery into main 2026-09-28 22:32:17 +00:00
jschoubben 709c095387 ADR 0141: a progressive insight — the delivery is not 'nothing new'
The record claimed a version reaches a machine as an ordinary archive with
nothing new needed. Two things it needs do not exist: no toolchain can compile
the host (the list is typescript and python, and the control plane, also Go, is
built as an image from a Dockerfile instead), and nothing interpolates a built
version into a resource path, so nothing can ask for .../versions/<version>/.

The decision, the options weighed and every consequence stand — the host half is
merged and tested. What was understated was the cost, so it is corrected in place
and dated rather than superseded.
2026-09-29 00:32:14 +02:00
mesh-admin c262de3833 Merge pull request 'ADR 0141: the host delivers its own successor' (#173) from decision/0141-the-host-delivers-its-own-successor into main 2026-09-28 22:22:28 +00:00
jschoubben a815433214 ADR 0141: the host delivers its own successor, and versions live side by side
The supervision was already right — a clean exit means the host stood aside and
the launcher runs what is on disk, failures are counted, and a rollback happens
at the limit. Two things made it dead code: nothing told the running host a
successor was waiting, and the rollback resolved its known-good version through
pacman, which no machine here uses and which two of three operating systems do
not have.

Keeping a version rather than a path was the clue. Versions live side by side in
directories named for them; the newest runs; the running one stands aside between
reconciles; a reconcile that completes records itself and retires what is older
than its predecessor; rollback starts that predecessor. No new resource kind and
nothing new on the bus — an archive already fetches by digest, and the path
written is never the path executing.

Answers issue 142.
2026-09-29 00:22:04 +02:00
mesh-admin 61dc90e508 Merge pull request 'Issue 142: the host is the one thing the mesh does not deliver' (#172) from issue/142-the-host-is-not-delivered into main 2026-09-28 22:03:58 +00:00
jschoubben a7cf5c0c1b Issue 142: the host is the one thing the mesh does not deliver
A host change merged yesterday reached no machine without a person copying a
file. The host is not a build target, no declaration delivers it, and the half
that recovers from a bad host — noticing the executable changed, a known-good
record, a launcher that rolls back — is written, tested and called by nothing.
All four machines run a byte-identical hand-copied binary that no package owns
and no record names, so nothing can say a machine is behind.

Found because ADR 0140 needs the machine to report a new fact, and merging that
could not roll it out.
2026-09-29 00:03:41 +02:00
mesh-admin c6f86ae935 Merge pull request 'ADR 0140: the filter constrains what arrives from outside' (#171) from decision/0140-filter-constrains-what-arrives-from-outside into main 2026-09-28 21:32:10 +00:00
jschoubben 1aeb4fe8d8 ADR 0140: the filter constrains what arrives from outside, and says nothing about a machine's own guests
Reading a converged machine's rendered rules showed the cause: the chain blocks
everything passing through and then allows the machine's own containers back by
listing their address ranges. 0137 made that list typeable and 0139 tried to
generate it; both refined a list that should not exist, because the mesh has no
position on a container reaching outward. Constrain what arrives from outside,
allow what did not, and let the machine report which links face outside — one
fact instead of a list. Ports keep following the modules unchanged.

The records check now allows one record to supersede several, and stops
requiring a withdrawn record's own citations to be live.
2026-09-28 23:31:50 +02:00
mesh-admin 08108b569d Merge pull request 'ADRs 0138 and 0139: an endpoint's reach, and networks forwarded because a module declared them' (#170) from decision/0138-endpoint-reach-and-0139-declared-networks into main 2026-09-28 21:03:49 +00:00
jschoubben 14ff89fa40 ADRs 0138 and 0139: an assignment binds an endpoint and says how far it reaches, and a network is forwarded because a module declared it
Both follow from the same rule the mesh is built on — a node's configuration is
composed from the modules assigned to it. Reach was settled separately by the
filter, the proxy's names and the certificate authority, so "this must not be
public" could not be written; it becomes one value on the assignment that all
three read. And the forward chain consulted two constants plus a typed list
although modules already declare their networks; it now forwards what they
declared, with the host rendering the addresses it allocated.
2026-09-28 23:03:32 +02:00
mesh-admin 2126e7b2cb Merge pull request 'Issues 140 and 141: an endpoint's reach, and a forward chain that does not follow the modules' (#169) from issue/140-endpoint-reach-and-141-forward-chain into main 2026-09-28 20:58:03 +00:00
jschoubben dcdfcf104e Issues 140 and 141: an endpoint's reach is declared nowhere, and the forward chain follows constants instead of the modules
Found preparing the control-node's convergence. Reach is settled independently by
the filter, the proxy's names and the certificate authority, so "this must not be
public" cannot be written and a public certificate is obtained regardless. And the
forward chain allows two hardcoded ranges plus a typed list, though the mesh
already knows which networks exist because its own modules declared them — a range
wide enough to keep four of them would have forwarded two predecessor leftovers too.
2026-09-28 22:57:38 +02:00
mesh-admin d23ace1646 Merge pull request 'Issues 138 and 139' (#168) from issue/138-the-uplink-seat-and-139-an-internal-route-name into main 2026-09-28 19:46:22 +00:00
jschoubben 5dbde0b13a Issues 138 and 139: a seat with interchangeable holders that are not, and an internal route name that resolves to the wrong machine 2026-09-28 21:46:20 +02:00
mesh-admin db3868e2b0 Merge pull request 'ADR 0137: a machine says which networks it routes' (#167) from decision/0137-a-machine-says-which-networks-it-routes into main 2026-09-28 19:31:03 +00:00
jschoubben 05039c4f10 ADR 0137: a machine says which networks it routes, and issue 137 is how that was found 2026-09-28 21:31:01 +02:00
mesh-admin b7bf601ca4 Merge pull request 'Issue 136: a module may name a program the machine does not have' (#166) from issue/136-a-module-may-name-a-program-the-machine-lacks into main 2026-09-28 18:50:37 +00:00
jschoubben bff32e3370 Issue 136: a module may name a program the machine does not have, and everything reports success 2026-09-28 20:50:35 +02:00
mesh-admin d6b62387f2 Merge pull request 'Issue 135: a container's mesh names are not compared' (#165) from issue/135-a-containers-mesh-names into main 2026-09-28 15:19:37 +00:00
jschoubben 4789624857 Issue 135: a container's mesh names are not compared, so a moved address is never noticed
One container restarted 2286 times over five days while the mesh reported the machine as doing what it
was told. Its overlay address was five days out of date: the host compares a container by a digest of
its spec, and the mesh's names were not in it, so a container whose image and files never changed was
left alone holding a name that no longer resolved. Forty-eight others were current only because
something else had recreated them.

The same fault as issue 045, in the field that was left out. Resolved by putting the names in the
digest.
2026-09-28 17:19:35 +02:00
mesh-admin e00862e317 Merge pull request 'ADR 0112 is accepted, and issue 134 records what it is not yet' (#164) from decision/0112-accepted-and-issue-134 into main 2026-09-28 15:12:15 +00:00
jschoubben 3a92e4b80c ADR 0112 is accepted, and issue 134 records what it is not yet
0120 was already accepted; what failed the check was that it rests on 0112, still marked proposed —
and so do four designs. The decision stands: a definition names no node, no mesh and no host path, and
everything a module needs is a requirement the mesh resolves.

Accepting it makes the gap visible rather than hiding it, so issue 134 states it. 0112 says how it is
checked — 'a catalogue test finds no domain name in any definition value' — and there is no such test.
Asked by hand: seven modules name this installation in a value the mesh acts on, and eight mention a
public name in prose nothing reads. The two are not the same fault and the fixes differ, which is why
the issue separates them rather than counting to fifteen.
2026-09-28 17:12:12 +02:00
mesh-admin bfddf78bf3 Merge pull request 'Design 32: what shipped today, and the records it rests on' (#163) from design/28-and-32-what-shipped into main 2026-09-28 14:50:44 +00:00
jschoubben 0b291d89f3 Design 32: what shipped today, and the records it rests on
Two of its statements are built — a version preparing its state, and the mesh saying what it applied —
so the document is in-progress rather than proposed, and names the code that owns them. Resting on a
live record rather than a superseded one: 0127 was replaced by 0131.

What this exposes is pre-existing: it also rests on ADR 0112, which is still proposed, and a document
that is not itself proposed may not. ADR 0120 has rested on it the same way for a while. Accepting or
superseding 0112 is a decision, not a cleanup, so it stays visible in the check rather than papered
over.
2026-09-28 16:50:41 +02:00
mesh-admin a924efcc28 Merge pull request 'ADR 0136: a step gates its module, not the machine' (#162) from decision/0136-a-step-gates-its-module into main 2026-09-28 13:38:18 +00:00
jschoubben b421c73a5a ADR 0136: a step gates its module, not the machine
ADR 0135 made a step something the mesh derives for any module that prepares its state, which turned
ADR 0052's reach into a fault: a module whose database is briefly unreachable would stop every module
declared after it on that machine — the fault issue 011 already removed for every other shape, and
the reason the catalogue migrates itself at start rather than in a step.

A step now stops the rest of its own module and nothing else; an action still gates the machine,
because genesis is a row of them and they belong to no module. What was not attempted is reported as
skipped rather than left to be inferred from silence.
2026-09-28 15:38:16 +02:00
mesh-admin 017b1401e4 Merge pull request 'ADR 0135: a module version prepares its state before it runs' (#161) from decision/0133-0134-migrations-and-deploy-facts into main 2026-09-28 09:57:47 +00:00
jschoubben 476cda417d ADR 0135 supersedes 0133: a module version prepares its state before it runs
Two faults in 0133, both caught on review. It put the declaration on a container — one resource kind
the host applies — so every author would restate the machine's arrangement and a module's own
lifecycle would be tied to how its artifact happens to run. A module declares entrypoints for its
tools and its provisioner; preparing its state is the same vocabulary and nothing about a runtime.

And it derived the scope from the machine, which the facts already answer: a consumer is a module on
a machine (issue 022, migration 0015), so what the mesh provisions is per consumer. A module on three
machines has three databases, there is no shared state to race over, and the lock obligation 0133
invented was for a situation the mesh does not produce. The level question HAL answered with stages
dissolves — the scope of preparation is the scope of the state, and the mesh knows it.

0133 keeps its reasoning and gains a pointer; design 32 and issue 133 name the live record.
2026-09-28 11:57:16 +02:00
mesh-admin 74ba3ff1d4 Merge pull request 'ADR 0133 and 0134: who runs migrations, and the mesh saying what it applied' (#160) from decision/0133-0134-migrations-and-deploy-facts into main 2026-09-28 09:45:49 +00:00
jschoubben 891c7a945e ADR 0133 and 0134: who runs migrations, and the mesh saying what it applied
0133 — a module owns its migrations and the mesh owns when they run. A container declares what must
run before it; the mesh derives the gated step from the resource it precedes, so the image, the
environment and the credentials come from the one place they are described. The module owns the SQL,
the dialect and the lock; the mesh owns the moment and refuses to start a version whose step failed.
Per node, with no level: a step that ran once somewhere leaves every other machine ungated, and
'once, mesh-wide' is what holding a seat already means.

0134 — the pipeline is observable from a merge to an artifact and goes dark at the machine. What a
node now runs, and what it refused, become facts under the control plane's own seat, emitted when
what a machine runs changes rather than on every convergence pass.

Design 32's lifecycle carries both; issue 133 points at them as what ends the matter it opened.
2026-09-28 11:45:47 +02:00
mesh-admin 83cbeb8db8 Merge pull request 'Issue 133: the control plane's schema is migrated at birth and never again' (#159) from issue/133-the-control-plane-migrates-before-it-serves into main 2026-09-28 08:27:32 +00:00
jschoubben ab6db9369b Issue 133: the control plane's schema is migrated at birth and never again
The mesh replaced its own control plane with a build carrying a migration, applied none of it, and
then recorded no build for three quarters of an hour while saying everything was fine. ADR 0052
already prescribes the shape — a run-once step that gates the server — and the control plane was the
one module that did not use it.
2026-09-28 10:27:30 +02:00
mesh-admin 84571b4825 Merge pull request 'ADR 0132: a seat carries the tools its holder must serve' (#158) from decision/0132-a-seat-carries-the-tools-its-holder-must-serve into main 2026-09-28 08:17:01 +00:00
jschoubben d57196102d ADR 0132: a seat carries the tools its holder must serve
A role's tools belong to the role, not to whichever module holds it today: the seat declares them
with their schemas, serving them is a condition of occupying the seat, and what the mesh can do
becomes a read of its own records rather than a question nothing answers. A module keeps its own
tools — the same module may run without the seat, and then only its own name is true.

Design 33 follows: the three families, addressing a node-scoped seat, discovery, and what serves
this to an agent.
2026-09-28 10:16:59 +02:00
mesh-admin e6402cf777 Merge pull request 'Issue 132: a module can be recorded without the directory it lives in' (#157) from issue/132-a-module-can-be-recorded-without-its-directory into main 2026-09-28 07:20:07 +00:00
jschoubben 4f0d144833 Issue 132: a module can be recorded without the directory it lives in
Nine modules could not be rebuilt: their record named the repository and no directory, so every
build looked for a manifest at a repository root that has never had one. Resolved by mesh-controller
— `module add` takes the directory and the forge, and the rule is checked rather than described.
2026-09-28 09:20:05 +02:00
mesh-admin 98d94ef71e Merge pull request 'Design 28: 5.5 done, the mesh has one bus; issue 131 resolved' (#156) from design/28-one-bus-issue-131-resolved into main 2026-09-28 01:59:41 +00:00
jschoubben 4e13280604 Design 28: 5.5 done, the mesh has one bus; issue 131 resolved
The AMQP transport is gone from the control plane and the hosts (mesh-controller #112,
mesh-host #39). On the way: no build had ever recorded its bases, so every bases-first order
walked an empty graph; the builder now reports what it was handed and the graph is read from
builds (mesh-controller #113/#114). Issue 131 is resolved by the forge module's merge event,
the control plane following it, and those edges.
2026-09-28 03:59:39 +02:00
mesh-admin 8783a13448 Merge pull request 'Design 28: the mesh runs on the new bus' (#155) from design/28-the-mesh-runs-on-nats into main 2026-09-28 00:40:29 +00:00
jschoubben a31cfcf461 Design 28: 5.3 is built and was used for the hand-over 2026-09-28 02:28:06 +02:00
jschoubben 75b3861911 Design 28: the mesh runs on the new bus
Tasks 4.3, 5.2 and 5.4 are done as of 2026-09-28 02:25: every machine reports on
the new bus, the seat is held by the module that provides it, the old broker is
unassigned and forgotten, and every credential was minted afresh at the end.

5.2 records what it took, in the order it was found and each fixed on the trunk
before the next step, and how the bootstrap loop was broken once, by hand.
2026-09-28 02:27:49 +02:00
jschoubben 694555214a Merge pull request 'Design 26: which assignment holds a seat is on record, and changes as one act' (#153) from design/26-a-seat-is-held-on-record into main 2026-09-27 21:22:56 +00:00
jschoubben a7249541df Design 26: which assignment holds a seat is on record, and changes as one act
Until now the holder was derived — assigned and claiming — and a second eligible
assignment was refused, so a seat could not pass from one holder to the next
without a moment where nobody held it. The controller finds its own bus through
one of these seats, and that moment took the control plane down on 2026-09-27.

The holder is now a row the controller keeps, written by `seat <name> --to
<node>/<module>` in the same write that removes the previous one. No row means the
old rule, so nothing changes for a mesh that never hands a seat over; with a row,
another eligible assignment is silent rather than refused, which is what lets the
next holder run beside the current one until the switch. A holding is the
assignment's and goes when it does. Each rule names the test that checks it.

Under ADR 0131; design 28 task 5.3 is the work.
2026-09-27 23:20:36 +02:00
jschoubben 95d8253f71 Merge pull request 'ADR 0131: everything on the mesh speaks to the broker seat, and AMQP is not a provision' (#152) from decision/0131-everything-speaks-to-the-broker-seat into main 2026-09-27 21:06:02 +00:00
jschoubben 784b487bf9 ADR 0131: everything on the mesh speaks to the broker seat, and AMQP is not a provision
Taken during the outage of 2026-09-27, when the protocol leaked into the seat's
contract: to hold mesh-broker a module had to provide amqp, so the module that
will carry the bus could not hold the seat that names the bus, while the module
being retired could. Supersedes 0127. Modules depend on the seat and reach the
bus through the sdk; no manifest provides or requires amqp; the old broker's
module and the two modules that required it leave the catalogue; the AMQP
transport is deleted once every node reports on the new bus.

Design 28 step 5 rewritten under it: the seat handover becomes its own task and
is built first, because the seat the control plane dereferences cannot be empty
in between — that emptiness was the outage. The cost note now carries what was
measured rather than what was assumed.

0128 and 0130 extended 0127; each now rests on 0131 with a dated note and
changes nothing it decided. Every other citation of 0127 names its replacement.
records.py still fails on 0120/0112, which predates this branch.
2026-09-27 23:03:05 +02:00
jschoubben 7ae711ba0b Merge pull request 'Building the bus: the decisions the work needed, and what it taught back' (#150) from feat/nats-genesis into main 2026-09-27 17:06:40 +00:00
43 changed files with 3221 additions and 73 deletions
+16 -3
View File
@@ -164,6 +164,12 @@ def check_rests_on(failures, records):
# decision is exactly what as-is is for."
if rel(path).startswith("03-DESIGN/00-as-is/"):
continue
# A withdrawn record's citations are history. It instructs nobody -- every reader
# is sent to its superseder -- so what it was built on may itself be withdrawn.
# Refusing that would mean rewriting the lineage of a record whose reasoning is
# the thing the immutability rule protects.
if frontmatter(read(path)).get("status") == "superseded":
continue
# An extension that supersedes legitimately names what it replaced.
this = ADR_FILE.match(os.path.basename(path))
supersedes = records[number]["front"].get("superseded-by", "")
@@ -241,13 +247,20 @@ def check_supersession_symmetry(failures, records):
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
continue
other = records[match.group(1)]
claims = os.path.basename(str(other["front"].get("supersedes", "")))
if claims != record["name"]:
# `supersedes:` may name one record or several. One decision replacing two is a real
# situation -- two records that built and refined the same wrong mechanism are withdrawn
# by the one record that removes it -- and a check that allows only one would force
# either a chain of pro-forma records or an unmarked supersession.
claimed = other["front"].get("supersedes", "")
if isinstance(claimed, str):
claimed = [claimed] if claimed else []
claims = [os.path.basename(str(entry)) for entry in claimed]
if record["name"] not in claims:
failures.add(
"supersession",
rel(other["path"]),
f"ADR {number} says this supersedes it; this record does not say so "
f"(supersedes: {claims or 'absent'})",
f"(supersedes: {', '.join(claims) or 'absent'})",
)
+1 -1
View File
@@ -40,7 +40,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It
keeps running as an **ordinary provider** of the `amqp` provision, for modules that need a
message broker of their own the way something needs a database
([ADR 0127](../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)) — no seat, not foundation,
([ADR 0127](../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))) — no seat, not foundation,
never raised at genesis, and a mesh that never installs it is complete.
Say *the deprecated broker*, not "the compatibility broker" (it serves the mesh's own modules,
@@ -1,6 +1,6 @@
---
topic: what runs on it
status: proposed
status: accepted
date: 2026-09-25
deciders: jochen
reconstructed: false
@@ -1,6 +1,7 @@
---
topic: the mesh
status: accepted
status: superseded
superseded-by: 0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
date: 2026-09-26
deciders: jochen
reconstructed: false
@@ -4,11 +4,18 @@ status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
extends: 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
---
# 128. The mesh bus is required, not ambient
> **Pointer repointed, 2026-09-27.** This record was written extending
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)), which
> [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) has since superseded — AMQP is
> not a provision at all. Nothing decided here changes; the frontmatter now rests on the live record,
> and the citations below are read with that in mind.
## Context
[Design 29](../03-DESIGN/01-to-be/32-what-a-module-declares.md) opened by saying the bus is
@@ -72,7 +79,7 @@ process in the path and nothing waiting on a bus account to create bus accounts.
whose provider is the mesh itself.
**A module may also provide a NATS server of its own, and that is a different interface.** Exactly
as the AMQP broker provides `amqp` ([ADR 0127](0127-amqp-is-a-provision-not-the-bus.md)), a module
as the AMQP broker provides `amqp` ([ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))), a module
may run its own NATS and offer it as a backing service. That interface is **`nats`**; the mesh's
own bus is **`mesh-bus`**; the two are never the same name, because a manifest that said `nats`
could mean either and the difference is the whole architecture. The rule from 0119 decides which
@@ -109,7 +116,7 @@ is legitimate: a private bus is a backing service, never a channel to another mo
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — superseded by 0119; its bootstrap argument is
narrowed here to the case it supports.
- [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) — a broker as a backing service; this
- [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) — a broker as a backing service; this
applies the same shape to the mesh's own bus and separates the two names.
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — authority from
declarations, which this leaves untouched.
@@ -4,14 +4,21 @@ status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
extends: 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
---
# 130. The predecessor is ending, and its broker goes with it
> **Pointer repointed, 2026-09-27.** This record was written extending
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)), which
> [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) has since superseded — AMQP is
> not a provision at all. Nothing decided here changes; the frontmatter now rests on the live record,
> and the citations below are read with that in mind.
## Context
[ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) settled that the old broker is an ordinary
[ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) settled that the old broker is an ordinary
provider of the `amqp` provision rather than a compatibility module with an end date. It rejected
giving it a retirement condition, and said why: *"its clients are not only the predecessor's, so the
retirement condition describes a day that will not come."*
@@ -0,0 +1,94 @@
---
topic: the mesh
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
supersedes: 0127-amqp-is-a-provision-not-the-bus.md
---
# 131. Everything on the mesh speaks to the broker seat, and AMQP is not a provision
## Context
[ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) settled the old broker as an ordinary provider
of an ordinary provision, `amqp`, kept for whatever wanted a message broker of its own. The day the
bus moved was the day that framing was tested, and it failed in a way that took the control plane
down for an evening.
Three things came out of the wreckage. **The protocol had leaked into the seat's contract**: for a
module to hold `mesh-broker`, it had to provide what the seat delivers, and what it delivered was
`amqp` — so the module that will carry the bus on NATS could not hold the seat that names the bus,
while the module the mesh was leaving could. **A consumer of `amqp` is not asking for AMQP.** The two
modules requiring it wanted the mesh's messaging — to emit an event, to hear a topic — and named the
wire protocol only because that was the word available. **And AMQP and NATS are not interchangeable
at the wire.** A provision named after a protocol can only ever be answered by that protocol, so once
the bus is NATS an `amqp` provision has one possible provider, and it is the thing being retired.
The operator's position, stated during the outage: modules depend on the broker *seat*, not on a
protocol; AMQP is obsolete as anything the mesh's core knows about; a module that depends on `amqp`
is wrong; and everything should reach the mesh's bus and be able to emit events and consume topics
through it.
## Decision
**A module that needs messaging uses the mesh's bus, and the mesh's bus is whatever holds
`mesh-broker`.** Emitting an event and consuming a topic go through the sdk, which is handed the
bus by the mesh with the module's own credential. No manifest names a wire protocol to get it.
**`amqp` is neither a provision nor a requirement.** Registration refuses a manifest that provides
it or requires it. The `mesh-broker` seat delivers `mesh-bus`, and its holder is the module that
provides `mesh-bus` — today the nats module, and only it.
**The old broker's module and the two modules that required it leave the catalogue.** They are
removed, not converted: one was a proof that a grant worked end to end, the other forwards mail off a
queue, and both are re-done against the bus if wanted, as new modules under this record.
**The controller's AMQP transport is deleted once every node reports on the new bus**, and the
switch that selects a transport goes with it — one bus, so nothing to select.
The predecessor's own broker is outside the mesh and not this record's concern
([ADR 0130](0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md)): what the predecessor's
tooling loses when it stops is accepted there.
## Options considered
1. **Keep 0127: AMQP stays an ordinary provision with the old broker as its provider.** Rejected. It
is what put the protocol into the seat's contract, it is why the seat could be left with no valid
holder mid-change, and it keeps two transports in the control plane indefinitely for the benefit of
two modules that did not want AMQP in the first place.
2. **Bridge it: the old broker's module also provides `mesh-bus`, so both can hold the seat during the
change.** Rejected. It makes the retiring broker a legitimate mesh bus for exactly as long as
nobody removes the line, which in practice is forever, and it leaves `amqp` as a thing the core
still knows the name of.
3. **The seat is the dependency; the protocol is nobody's business but the holder's.** Adopted.
## Consequences
- **The change of holder is a handover, and it needs a command.** Nothing today moves a seat from
one assignment to another as one act, and a seat the control plane dereferences cannot be empty
in between — that emptiness is the outage this record comes from. The command takes a seat and the
assignment taking it over. Designed and built before the cutover, under
[28 — Building the bus](../03-DESIGN/01-to-be/28-building-the-bus.md).
- **The seat's row moves to `mesh-bus` before the new holder registers, and that is safe.** The
control plane composes its own bus address through the seat *by name*
(`${seat:mesh-broker:…}`), and the overview derives holders by name; only registration and the
provision-to-seat resolution read what a seat delivers. So the row can change under the current
holder without unseating it, the new holder can then register its claim, and the handover happens
when both are running. Verified in the code during the outage, not assumed.
- **Registration gains two refusals**: a manifest providing `amqp`, and one requiring it.
- **The `rollout check` stops saying the old broker stays.** It said so under 0127; it now lists
unassigning it as the last step of the move.
- **What got harder**: a third party that genuinely wants an AMQP broker on a mesh node runs one as
any application module, with no provision and no seat, and nothing on the mesh routes to it. That
is the cost of the mesh not knowing the word.
## How this is checked
| Rule | Checked by |
|---|---|
| No manifest provides or requires `amqp` | a registration test refusing each, naming this record; and a whole-catalogue test asserting no registered manifest names it |
| `mesh-broker` delivers `mesh-bus`, and only a `mesh-bus` provider may hold it | the existing registration test for a delivering seat, with the row's value read from the store (mesh-controller#89) |
| The seat's row can change without unseating the holder | a test composing the control plane's own address and the overview under a row that the current holder does not satisfy |
| The rollout does not leave the old broker running | `rollout check` output, asserted in its test |
| The AMQP transport is gone | the package does not compile with it referenced; the switch variable is refused as unknown at start |
@@ -0,0 +1,150 @@
---
topic: the mesh
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md
---
# 132. A seat carries the tools its holder must serve
## Context
[ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) gave a seat the protocol of its role in
three parts: the work it accepts, the events it emits, and the verbs it **serves** — request and
reply, awaited. The bus already derives authority from all three: a holder subscribes
`mesh.seat.<seat>.tool.<verb>`, and a module that uses the seat may publish it and nothing else.
**The serving third has never been used.** The mesh defines 14 seats, 8 mesh-scoped and 6
node-scoped. Exactly one carries a protocol at all — the build machine, which accepts `build` and
emits `built`. Not one seat declares a single verb it serves. The mechanism is built, enforced, and
empty.
Meanwhile every tool on the mesh is addressed to a module. Of 72 modules in the catalogue, 45 serve
tools, about 203 of them, each on `mesh.mod.<module>.tool.<name>`. So a caller binds to the module
that happens to hold a role rather than to the role, and replacing that module breaks every caller —
which is the thing seats exist to prevent everywhere else.
**Nothing can say what tools exist.** Measured on 2026-09-28, with the bus carrying the whole mesh: a
workstation client holding an operator credential connected, the bus accepted the account, and
`mesh call gitea.gitea_list_repos` answered with real repositories. The same client's `mesh tools`
found nothing, because it asks `mesh-catalog.catalog_tools` and no module serves that: the catalogue
serves `catalog_modules`, `catalog_module`, `catalog_provides`, `catalog_dependents` and
`catalog_stale`. An agent can therefore call any tool it already knows the name of and discover none.
MCP's `tools/list` is that same question, so the MCP surface is a working transport over an empty
catalogue.
**And there is nowhere for a tool's definition to live.** A manifest has a `tools` field: 0 of the 45
modules that serve tools fill it. That is not neglect, it is the arrangement failing — the field was
the bus grant's source for what a module may subscribe, and because nothing filled it every module
that served a tool was refused its own subscription on the new bus, live, until the grant was changed
to the module's own namespace. Today a tool's name, description and argument schema exist only in the
module's code.
Two facts about the machinery matter for what follows. A seat's protocol is not in the store: the seat
rows lack the ADR 0129 columns, so the protocol comes from compiled defaults and is merged in when a
row is read. And `seatSubject` is flat — `mesh.seat.<seat>.<kind>.<verb>` with no node in it — so a
node-scoped seat's tool call would reach every node's holder at once, and the holders' queue group
would hand it to whichever answered first.
## Decision
**A seat's protocol carries its tools in full**: the verb, what it does, and the schema of its
arguments and of its answer. The seat is the definition of the role's interface; the holder is an
implementation of it.
**Serving the seat's tools is a condition of holding the seat.** A module that does not serve every
verb the seat declares may not occupy it. This is checked where the other conditions of holding are
checked — registration and handover — and refused by naming the verbs that are missing.
**A role's tools are addressed to the role.** `mesh.seat.<seat>.tool.<verb>` mesh-wide. A node-scoped
seat carries the node in the address, because one subject reaching six machines' holders is not an
address, and the queue group that made it look like one would silently pick a winner.
**A module keeps its own tools, and both exist.** `gitea_list_repos` stays, because gitea can run
without holding the `git` seat — a second forge, an instance kept for one purpose. The module's name
answers *this gitea*; the seat's verb answers *whoever is the forge*. Which of the two a caller wants
is a decision in the running session, not one the mesh makes for it.
**What answers "what tools exist" follows where the definition lives.** A seat's tools are read from
the mesh's own records. A module's own tools are answered by the module, from the code that defines
them. Discovery is therefore a read for the durable half and a question to the running mesh for the
free half.
**A seat's tools are an interface, and change like one.** Additive within a version; a change that
would break a caller takes the version token the subject already has room for (design 29 §8), and the
two run side by side until nothing is bound to the old one.
**The mesh's own verbs are the `mesh-controller` seat's tools.** `status`, `push`, `build`, `assign`
and the rest are a role's interface, not a container's, and the audit point [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md)
asks for is the seat's holder.
## Options considered
1. **The manifest declares each module's tools.** Rejected. The list is then written twice — in the
manifest and in the code — and a schema in a manifest goes stale silently, which is the worst kind
of wrong for something an agent reads to decide what to call. It is also the arrangement that has
already failed once: the field exists, 0 of 45 modules fill it, and the grant that depended on it
refused every tool subscription on the mesh.
2. **Every runtime answers an introspection call, and something aggregates them.** Rejected as the
shape for a role's tools, kept for a module's own. An aggregator needs permission to publish into
every module's namespace, which is a widening the mesh otherwise gives only to the control plane;
and the answer is only as available as the modules are, so a mesh whose catalogue cannot say what a
role answers while its holder is down cannot plan against it.
3. **The control plane answers everything.** Rejected. It puts a tool surface on the control plane for
tools it does not implement, and makes discovery depend on the one component that must stay
answerable while it is itself being replaced. The mesh's own verbs are its to answer, and it answers
them as the holder of a seat.
4. **Seats only; no module tools.** Rejected. Most modules hold no seat, and inventing a seat per
module to give its tools a home would dilute what a seat is: one holder of a role the mesh needs
exactly one of.
## Consequences
**One capability can have two names, deliberately.** A forge that holds the `git` seat answers both
`mesh.seat.git.tool.list_repos` and `mesh.mod.gitea.tool.gitea_list_repos`. This is the one place the
mesh accepts two names for one thing, because they are answers to different questions and the second
one survives the module not holding the seat. The glossary rule stands everywhere else.
**A seat becomes a contract to implement.** Adding a verb to a seat is a change every holder must
make, and a claim that was valid becomes invalid until it does. That is the point, and it is also the
reason a seat's tools should be few and durable while a module's own stay free.
**Three prerequisites, none of them in place.** The seat's protocol must be in the store rather than in
compiled defaults, or discovery reads a binary rather than the mesh. The protocol must become richer
than a list of verbs, because a verb without a schema is not something an agent can call. And a
node-scoped seat needs the node in its subject before any of its tools can exist.
**Discovery becomes cheap for the half that matters.** What roles the mesh has and what each answers is
a query, with no fan-out and nothing to be up. An agent's authority can then be role-shaped — *the
forge's tools* — rather than a list of module-specific names that changes when a module is replaced.
**The MCP surface belongs inside the mesh.** Once the tools are the mesh's own records, the thing that
serves them to an agent is a module the mesh assigns to the machine where the agent sits, with a
credential the mesh minted and authority derived from what it may call — not a program started by hand
with a credential printed to a terminal.
## How this is checked
- **Holding is refused without the verbs.** The condition sits with the other conditions of holding a
seat, so registration and a handover both refuse a module that does not serve what the seat declares,
and the refusal names the missing verbs. A test per condition, as the other seat conditions have.
- **The grant is derived from the seat, and already is.** A holder's subscription and a user's publish
come from the seat's protocol, so a verb nobody declared is a subject nobody may use, and a verb the
seat declares reaches exactly its holder. The golden composition of the bus's user list is the test
that keeps it honest.
- **Discovery is a read, and is tested as one.** What the mesh answers for a seat's tools equals what
the seat's records declare — no call to a module in the path, so the test needs no running module.
- **A node-scoped seat's subject carries its node**, checked by the same test that checks the subject
table: two nodes holding one node-scoped seat derive two addresses.
## References
- [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) — the protocol this widens
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the role, its work and its events
- [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md) — a tool call passes one process where an audit belongs
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — an event is addressed to its emitter, for the same reason a role's verb is addressed to its role
- [`03-DESIGN/01-to-be/26-the-seats.md`](../03-DESIGN/01-to-be/26-the-seats.md) — how a seat is held and handed over
- mesh-controller #116, #117, #118 — the grants as they now stand: a module serves its own namespace, the control plane may ask any tool
- Measured 2026-09-28 on the live mesh: an operator credential calling a module's tool over the bus answers; `tools/list` finds nothing
@@ -0,0 +1,159 @@
---
topic: what runs on it
status: superseded
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md
superseded-by: 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md
---
# 133. A module owns its migrations, and the mesh owns when they run
## Context
On 2026-09-28 the mesh replaced its own control plane, through its own upgrade path, with a build
carrying a migration. Nothing applied it. For the next three quarters of an hour every build the mesh
made was refused by the store with one line — *column "built_contexts" does not exist* — which reached
only whoever happened to be waiting on that build's reply. The images were built and published, so the
registry filled with artifacts the mesh has no record of, and the overview went on reporting that every
module was current ([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
The schema had been created once, at genesis, by an action in the foundation bundle. Nothing ran it
again, through many updates of the control plane since.
**The mechanism to do this right already existed and one module used it wrong.**
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) makes a run-once container a step the host
runs to completion before whatever the declaration places after it, and names migrating a schema as the
case it exists for. Three facts about how it is used today:
- The control plane's manifest had no step at all. The immediate fix was to write one by hand, and that
hand-written step repeats three environment variables and three volume mounts from the server
resource it precedes — six chances to drift from the thing it prepares.
- Two other modules hand-write the same shape for the same reason: gitea's admin bootstrap and
mosquitto's dynsec seed, each repeating its sibling's image, environment and mounts. One of them
ends in `|| true`, which is a lock implemented as a shrug.
- The catalogue module takes the other road: it migrates its own schema in its own code when it starts.
That failure mode is a crash loop rather than a stop — the catalogue restarted 338 times this
morning on an unrelated start-time failure, and nothing anywhere said the mesh's graph had a gap.
**What the mesh already has, and what HAL needed stages for.** Ordering a provider before its consumer
is `providersFirst`, which topologically orders a node's modules. Ordering within a module is
declaration order, and a run-once container gates everything after it. Remembering that a step has
already run is the digest of its declaration, recorded only after it exits 0
([ADR 0018](0018-a-picture-is-read-from-what-runs.md)) — and the image is part of that digest, so a new
build re-runs it. Three of the four things a stage system provides are therefore already here. The
fourth — that a module has a schema at all — is the only thing missing.
**Nothing in the catalogue ships a migrations directory.** Of 72 modules, none has one; the modules that
migrate do it in their own code. So this is not a decision about where SQL files live. It is a decision
about who runs them and when.
**Two facts bound what is safely expressible.** A node converges toward its own declaration without
waiting on any other node. And of the five modules that run on more than one machine today — dnsmasq,
fail2ban, networking, networkmanager, sshd — not one wants a store; every module with a database is on
exactly one machine.
## Decision
**A container may declare steps to run before it.** The same container, run to completion, with
different arguments, in order, before it starts. The mesh derives the run-once resources from that
declaration, so the image, the environment, the volumes, the network and the credentials come from the
one place they are already described and cannot drift from it.
**A module's migrations are the first user of this, and the module owns them entirely.** The SQL, the
order, the idempotence, the lock, and which dialect it speaks. The mesh never learns that postgres and
mssql differ, because it runs the module's own image with the module's own arguments against the
module's own binding and requires exit 0. A module needing both stores runs one step that does both.
**The mesh owns the moment, and the gate is the guarantee.** Whether a version may serve when its
schema is not there yet is a deployment question, and the mesh is the only thing that can answer it,
because the mesh is what starts the container. A step that fails stops the container it precedes, so
a failed migration is a version that does not serve rather than a version serving against a store it
does not match.
**Per node, and there is no level.** The step runs wherever the module runs. A step that ran "once,
somewhere" would leave every other machine with no gate at all, and additive migrations protect old
code against a new schema, never new code against an old one. The cost is an obligation a migration
runner already carries: a version table and a lock.
**"Once, mesh-wide" is what holding a seat means.** A step that is not idempotent — seeding an
account, sending a notice, taking a backup — belongs to a module that holds a seat, where the mesh
already guarantees one holder, on record, handed over deliberately. That is the answer to the level
question rather than a field that has to invent an election and keep it somewhere.
**Migrations are forward-only and additive.** The step runs before the *new* container starts, so the
old one is still running against the new schema for the length of the apply.
**Declared, never inferred.** The control plane cannot see inside an image, so a module that ships
migrations and declares no step is not refusable at registration; it breaks on its first upgrade. This
record says so rather than implying a check that cannot exist.
## Options considered
1. **Each module migrates itself when it starts** — what the catalogue does today. Rejected: it turns a
schema failure into a crash loop instead of a stop, it is invisible in the declaration so nothing can
say the module even has a schema, and two machines running the module both migrate at start with
nothing sequencing them.
2. **The mesh applies migrations itself**, with a driver and a version table per store — HAL's shape.
Rejected: the mesh would have to know one store type from another, hold another module's store
credentials, and reach a machine to use them, which [ADR 0005](0005-the-node-host.md) forbids. It is
also the reason that shape needs levels: something central has to decide where the once happens.
3. **A hook lifecycle** — pre-build, post-build, pre-deploy, post-deploy. Rejected: there is no deploy
event here to hook. A declaration is a desired state applied in order and reconciled forever, so
"pre-deploy" is exactly "a step before this container", pre- and post-build are what a Dockerfile and
the artifact list already are, and "post-deploy" has no moment to name.
4. **A hook level** — once per module, or once per module-node assignment. Rejected as a field, kept as
a property: see the decision. A once-per-module step needs cross-node ordering underneath it to be
safe, and a node converging without waiting on its neighbours is worth losing on purpose rather than
by accident.
5. **Every module hand-writes its own run-once step** — the immediate fix for the control plane.
Rejected as the general answer: it duplicates the resource it precedes, in three places already, and
a hand-written step is one the next module forgets. Forgetting it is the fault this record exists
for.
6. **Record a schema level per module in the store.** Rejected: gating makes the invariant true by
construction, so a level is a second account of the same fact and the first one to go stale.
## Consequences
**Three hand-written steps collapse into one line each**, and the control plane's own migrate step stops
repeating its server's environment and mounts.
**The catalogue's self-migration becomes the exception to remove.** One shape, and the mesh's own
control plane is not an exception to it either.
**A module on two machines with one shared store must lock.** Today none is, so this is an obligation
stated before it is needed rather than discovered by two concurrent migrations.
**There is still no readiness-gated step.** Only an action carries `verify`; a container has no health
notion, so "run this once the service answers" remains unexpressible and seeding through a running
service's API has no home. That is its own decision about a container's readiness, and this record does
not make it.
**Genesis keeps its own action.** At birth there is no control plane to derive anything from, which is
what [ADR 0067](0067-genesis-is-a-pivot.md) already says about that moment.
## How this is checked
- **The composition carries the step.** A test on a node's composed declaration: every container that
declares steps before it is preceded by them, and the derived step's image, environment, volumes and
network equal the container's — so the two cannot drift, which is the failure the hand-written kind
has.
- **A failed step stops what follows.** The host already refuses to go on past a run-once step that did
not exit 0; the test for that is extended to a derived one, so the gate is checked rather than
assumed.
- **The mesh's own schema is covered by the same mechanism as everything else.** The control plane
declares its step in its own manifest, so the case that failed on 2026-09-28 is the case the test
covers.
- **A module claiming a seat for a once-only step is checked where seats are checked** — the conditions
of holding, not a new mechanism.
## References
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the step this extends
- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — a digest is the record that something happened
- [ADR 0005](0005-the-node-host.md) — the control plane decides and never touches a machine
- [ADR 0067](0067-genesis-is-a-pivot.md) — why genesis does it differently, once
- [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md) — the failure that produced this record
- [`03-DESIGN/01-to-be/32-what-a-module-declares.md`](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §6 — the lifecycle this sits in
- Measured 2026-09-28: three hand-written run-once steps repeating their sibling's resource; 0 of 72 modules with a migrations directory; 5 modules on more than one machine, none of them wanting a store
@@ -0,0 +1,126 @@
---
topic: the mesh
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
---
# 134. The mesh says what it applied
## Context
The pipeline is observable on the bus from a merge to an artifact, and modules already plug into it:
the forge emits `pull.merged`, the build machine's seat emits `built`, the catalogue emits `registered`,
`upgraded` and `rebuild-needed`, providers emit `postgres.database.provisioned` and its siblings. Things
consume them today — the catalogue consumes `built`, each provider consumes its own provisioning events,
model-usage consumes `*.usage.*`, the audit logger consumes `**`. Nothing had to be invented for any of
that; subscribing *is* plugging in.
**It goes dark at the moment it touches a machine.** A host applies a declaration and reports to the
control plane on the control branch, which only the control plane may read — correctly, because a report
carries what a machine is and enrolment travels the same way. So nothing on the mesh says *this machine
now runs version Y of module Z*, or that it refused to, or why.
What that cost on 2026-09-28, in one morning:
- A build result the store refused was visible only to whoever was waiting on that build's reply. For
three quarters of an hour the mesh built things and recorded none of them, while the overview said
every module was current ([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
- A module crash-looping at start — 338 restarts — was found by reading a container's logs by hand.
Nothing on the bus said the mesh's graph had stopped learning.
- A run-once step that fails now stops an upgrade by design ([ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)),
and the same silence would cover it: the version simply would not appear.
**And the one thing the control plane does emit is refused by its own account.** Answering a catalogue
that asks to catch up, it publishes each recorded build under `mesh.mod.control-plane.event.…` — a
module namespace for a module that does not exist. Its own permissions refuse it, so a catalogue that
restarts gets nothing and keeps its gap. The control plane has facts to state and nowhere to state
them.
## Decision
**The mesh emits the deploy half of the pipeline as facts on the bus.** What a machine now runs, and
what it refused to run and why. Both are facts about the mesh doing its work, in the same form as every
other fact on the bus, so anything that wants them subscribes the way the catalogue subscribes to
`built`.
**The control plane states them, as the holder of the `mesh-controller` seat.** Its facts live under the
seat's own namespace, which is where a role's events belong
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
[ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)) and which survives the control plane being
replaced. That is also what gives the catch-up replay a subject it may publish instead of an invented
module namespace.
**Emitted when what a machine runs changes, not on every convergence pass.** A host reconciles
continuously and reports each time; a fact per pass would be a fact per minute per machine that says
nothing. The report carries the declaration it applied and what changed, so the control plane has what
it needs to speak only when there is something to say.
**A refusal is a fact with a subject in it** — which machine, which resource, and the reason as the host
gave it. A refusal that names only the machine is the silence this record is about, one level up.
**Reports stay where they are.** A node's report remains control traffic that only the control plane
reads. The deploy facts are derived from it, which makes them second-hand on purpose: one emitter, one
ordering, and no widening of the narrowest account in the mesh.
## Options considered
1. **Leave it as it is, and let whatever cares ask the control plane.** Rejected: asking for a fact that
already arrives is the shape the mesh removed everywhere else, and nothing can react at the moment a
machine changes — which is exactly when a graph, an audit or an operator wants to know.
2. **Each node emits its own facts.** Rejected: it widens every host's account to an event namespace, and
a host's authority is deliberately the narrowest in the mesh. Its report already reaches the one thing
that can speak for it.
3. **Widen who may read the control branch.** Rejected: that branch carries what machines say *to* the
control plane, enrolment included. Widening its readers widens that too, for an unrelated reason.
4. **A registry of deploy hooks** — something registers interest and is called. Rejected: an event is
already the mechanism; there is nothing to register, and a callback is an address the mesh spent
[issue 102](../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)
learning not to keep.
5. **Put the facts on the node's own declaration stream.** Rejected: that stream is last-per-subject by
design, so a machine away for an hour gets exactly the current declaration and nothing older. A
history of what happened cannot live in a stream built to forget.
## Consequences
**The audit logger gets the deploy half for nothing**, because it consumes everything.
**A failure becomes visible where the mesh is watched** rather than where someone happened to be
looking. That answers the open question [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)
left about a record the store refused.
**The catch-up replay stops being a burst of events.** With the control plane able to state its own
facts, replaying history as if it were happening now is a choice rather than the only option — and the
better shape is the question the catalogue is actually asking, answered once
([design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)).
**The facts are second-hand.** The control plane says what a machine reported, so a machine that cannot
reach the bus produces no fact at all. Absence is not health, and what a machine was last heard from
stays the place that says so.
**The events stream carries more.** Bounded by emitting on change rather than on every pass, and each
fact is small; the stream's own limits remain what keeps it finite.
## How this is checked
- **The control plane's grant names exactly the subjects it emits**, derived from its seat like every
other principal's, and the composed user list is compared against a golden file — so a fact it cannot
publish fails a test rather than a catalogue's replay.
- **A convergence that changed nothing emits nothing.** A test with two identical reports and one
expected fact, because the failure this guards against is a fact per minute per machine.
- **A refusal names its resource.** A test where a host reports a failed resource and the emitted fact
carries which one and why, not merely that something went wrong.
- **What the mesh emits is what something consumes.** The subject a module declares it consumes derives
to the subject the control plane publishes — the same agreement test that already keeps the
controller's own subscriptions honest.
## References
- [ADR 0041](0041-events-are-a-relationship.md) — an event is a relationship, not a call
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — an event is addressed to its emitter, because the emitter's identity is the meaning
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) — a role's events belong to the role
- [ADR 0083](0083-one-push-leaves-the-mesh-consistent.md) — a report is held for the store rather than lost
- [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) — the gate whose failure this makes visible
- [`03-DESIGN/01-to-be/32-what-a-module-declares.md`](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §6 — the lifecycle, which ends today at a report nobody else may read
- Measured 2026-09-28: 45 minutes of builds recorded nowhere with the overview reporting health; a module at 338 restarts found by hand; the control plane's only emitted event refused by its own permissions
@@ -0,0 +1,153 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
supersedes: 0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md
---
# 135. A module version prepares its state before it runs
## Context
[ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) settled who runs a
module's migrations and when, and it said so in the wrong vocabulary. It put the declaration on a
*container* — "a container may declare steps to run before it" — and derived the scope of the work from
the *machine*. Both are wrong at the level a module author works at, and the second is wrong on the
facts.
**A container is one resource kind the host applies.** A module has code, state and a version; whether
its artifact is an image, a bundle or something later is the mesh's business. The module-facing
vocabulary for a module's own code already exists and has nothing to do with a container runtime: a
module declares **entrypoints** — this file is my tools, this file is my provisioner — and the mesh runs
them. A manifest that says "run this container with these arguments, and here are the volumes and
environment again" has an author writing down the machine's business twice.
**And the scope is not the machine's to decide, because the mesh already decided what a state is.** A
consumer is a module *on a machine* (migration 0015, from
[issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md)):
the mesh derives a login per consumer and the provider creates a database owned by exactly that login
([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). So a module on three machines is three
consumers, three credentials and three databases. There is no shared state for two machines to race over,
and ADR 0133's central caveat — that a module's migrations must take a lock because two machines might
migrate at once — describes a situation the mesh does not currently produce.
That correction makes the whole "level" question HAL answered with stages disappear: the scope of
preparation is the scope of the state, and the mesh knows it.
What the earlier record got right and this one keeps: the module owns the work, the mesh owns the moment,
the gate is the guarantee, migrations stay forward-only, and none of it can be inferred from inside an
artifact. What produced it also stands — the control plane was replaced with a build carrying a migration,
nothing applied it, and for three quarters of an hour every build was refused by the store with one line
that reached only whoever was waiting on a reply
([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
## Decision
**A module version declares an entrypoint that prepares its state.** One name in the manifest, in the
same vocabulary as the entrypoints it already declares for its tools and its provisioner. No container,
no command line, no environment, no mounts — those are how a machine runs the module's code, and the
module already said that once.
**The mesh runs it as it runs that module's own code, to completion, in the module's own context.** Every
binding, credential and setting the module's code would receive, because it *is* the module's code. How a
machine does that is the host's business and stays there: for an image artifact it is the step
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) already defines, and a later kind of artifact
changes the host, not the manifest.
**Preparation gates the version.** A version whose preparation did not succeed does not run — anywhere.
Since the rollout already sends machines one at a time and stops at the first that does not take a
version, a preparation that fails stops the rollout there, leaving every other machine on the version
that works.
**Preparation is scoped to the state, and the mesh derives that scope.** State the mesh provisions is per
consumer — a module on a machine — so preparation happens once per consumer. State the module keeps on
the machine is per machine, which is the same answer. A module that holds an exclusive seat has one of
itself, so its preparation happens once by definition. No level, no election, no cross-node ordering, and
no lock obligation invented for a race the mesh does not create.
**Once per version per state.** A version bump attempts preparation once against each state it has; the
module's own runner decides there is nothing to do, which is what a runner with a version table does
anyway. A retry after a partial failure runs it again, so the work is the module's to make safe against
that — the one obligation no design can remove.
**Forward-only and additive.** Preparation runs while the previous version is still serving, so a
migration that removes or renames what the old code reads breaks the mesh in the window between the two.
**Declared, never inferred.** The control plane cannot see inside an artifact, so a module that ships
migrations and declares no entrypoint is not refusable at registration. It breaks on its first upgrade,
and this record says so rather than implying a check that cannot exist.
## Options considered
1. **A container declares steps before it** — [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md).
Superseded, not because the mechanism is wrong but because the *declaration* is in the wrong place: it
makes every module author restate the machine's arrangement, and it ties a module's own lifecycle to
one resource kind. The host-side mechanism it named is retained and is now an implementation detail.
2. **Each module prepares itself when it starts** — what the catalogue does today. Rejected: a schema
failure becomes a crash loop rather than a stop, nothing in the declaration says the module has a
state to prepare, and the version serves the moment it starts rather than after the state is right.
3. **The mesh applies migrations itself**, with a driver and a version table per store type. Rejected:
the mesh would have to know one store from another, hold another module's credentials and reach a
machine with them, which [ADR 0005](0005-the-node-host.md) forbids. It is also what forces a stage
system: something central has to decide where the work happens.
4. **A hook lifecycle** — pre-build, post-build, pre-deploy, post-deploy. Rejected: a declaration is a
desired state reconciled forever, so there is no deploy moment to hook. "Pre-deploy" is exactly this
record; pre- and post-build are what a recipe and the artifact list already are; "post-deploy" names
nothing that happens.
5. **A declared level** — once per module, or once per assignment. Rejected: the mesh already knows what a
state is, so asking an author to choose is asking them to restate a fact the mesh holds, with a chance
of contradicting it.
6. **Record a preparation level per module in the store.** Rejected for the reason ADR 0133 gave and this
record keeps: gating makes the invariant true by construction, and a level is a second account of the
same fact.
## Consequences
**An author's whole contract is one line, once.** Write the migration in the module's code, name the
entrypoint that runs it, and every later version rolls out as: build, prepare, run — with nothing
per-version to remember and nothing about the machine to restate. That is the property this exists for.
**Three hand-written steps in the catalogue collapse**, and the control plane's own migrate step stops
repeating its server's environment and mounts.
**The catalogue's self-preparation becomes the exception to remove.** One shape, and the mesh's own
control plane is not an exception either.
**A module scaled across machines with one shared state is not expressible**, and this record does not
make it so. The mesh gives each consumer its own state; a deliberately shared one is a different
provision model, and the place the "once, mesh-wide" question would genuinely return. Named here so it is
a decision when it happens rather than a surprise.
**There is still no readiness-gated step.** Only an action carries `verify`; nothing declares that a
service answers, so preparation that must happen *after* something is serving — seeding through its own
API — remains unexpressible.
**Genesis keeps its own action.** At birth there is no control plane to derive anything, which is what
[ADR 0067](0067-genesis-is-a-pivot.md) says about that moment.
## How this is checked
- **The composition carries the preparation, in the module's own context.** A test on a node's composed
declaration: a version declaring a preparation entrypoint is preceded by it, and what it is given
equals what the module's own code is given — asserted equal rather than written twice, which is the
drift the superseded shape invited.
- **A preparation that fails stops the version.** The host does not go past a step that did not complete,
and the rollout stops at the first machine that did not take a version. Both are existing behaviours
with existing tests; the test for preparation asserts the two together — the machine does not run it,
and the machines after it are left alone.
- **Once per version per state.** A test that a second convergence of the same version prepares nothing,
and that a new version prepares again.
- **The mesh's own control plane declares one.** The case that failed on 2026-09-28 is the case the tests
cover, rather than a case a comment says is covered.
## References
- [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) — what this supersedes, and why
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the host-side step that implements it for an image artifact
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md), [issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md) — a consumer is a module on a machine, which is what makes the scope derivable
- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — a digest is the record that something happened
- [ADR 0005](0005-the-node-host.md) — the control plane decides and never touches a machine
- [ADR 0134](0134-the-mesh-says-what-it-applied.md) — what makes a failed preparation visible
- [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md) — the failure that produced both records
@@ -0,0 +1,106 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md
---
# 136. A step gates its module, not the machine
## Context
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) made a run-once container a step the host
runs to completion, and gave it the same reach a failed action has: it stops everything the declaration
places after it. When the only steps on the mesh were a broker's seed and a forge's admin account, that
reach was invisible — the thing after the step was the container the step existed for, in the same
module.
[ADR 0135](0135-a-module-version-prepares-its-state-before-it-runs.md) made a step something the mesh
derives for **any** module that prepares its state, and that turns the reach into a fault. A module
whose database is briefly unreachable now stops every module declared after it on that machine, for as
long as it is unreachable.
**The host already rejected this for every other shape, and says why in its own loop.** From
[issue 011](../04-ISSUES/011-one-broken-module-blocks-every-other/00-report.md):
> It used to stop at the first one, and that made one broken resource hold the whole machine hostage: a
> module declaring a package that does not exist meant every module ordered after it was never applied,
> for ever, and the mesh reported "failed" without saying that the rest had not been tried. A machine
> with one bad module and nine good ones ran none of the nine.
Everything is attempted and every failure reported — except an action and a run-once step, kept as the
deliberate exceptions. So the mesh has two rules about the same question and the wider one is now
reachable by any module that declares a schema.
**And it deadlocks a case the catalogue already named.** The catalogue migrates its own schema when it
starts rather than in a step, and says why in its code: *a schema step that had to reach the provider
over the overlay would block the very apply that brings the overlay up*. With a machine-wide gate that
is exactly right — the step fails, the apply stops, the overlay module after it is never applied, and
the next reconcile is blocked the same way. The module that most obviously wants a step could not have
one.
## Decision
**A step gates its own module.** A run-once container that does not complete stops the rest of *that
module's* resources and nothing else. Every other module on the machine is attempted, as every other
shape already is.
**An action still gates the machine.** Genesis is a row of actions, each making the next possible, and
they belong to no module — there is nothing narrower for their reach to be.
**What was not attempted is reported, not inferred from silence.** A skipped resource appears in the
machine's account of the apply as skipped, with the reason, because "not attempted" and "nothing to do"
are different answers and only one of them is somebody's to fix.
**A module is the part of a resource's identity before the first dot**, which is how the mesh composes
them. What the mesh declares in its own right — a guard, an opening, the adoption's own resources —
belongs to no module, and its gate is therefore the machine's.
## Options considered
1. **Leave the reach as it is.** Rejected: it reintroduces, through a mechanism now derived for every
module, exactly the fault issue 011 removed. A mesh where one module's unreachable database stops a
machine converging is worse than one where that module alone is behind.
2. **Make preparation not a gate at all** — run it and carry on. Rejected: then a version serves against
a state nobody shaped, which is the whole of what ADR 0135 exists to prevent.
3. **Order every module's step before everything else on the machine**, so a gate stops nothing that
matters. Rejected: it inverts the order a module needs — its files and directories are declared before
its step because the step reads them — and it would still stop later modules.
4. **Let a module declare how far its step reaches.** Rejected: the answer is the same for every module,
and a field would let one be wrong about it.
## Consequences
**The catalogue can move to a step.** The reason it migrates at start — that a step blocks the apply
that would make its provider reachable — stops being true: the step fails, that module waits, the
overlay comes up, and the next reconcile prepares it. One shape for the whole mesh, which is what
ADR 0135 asked for and could not have had.
**A module can sit behind while the machine is otherwise current.** That is the honest state and it is
what the report now says. It also means a preparation that never succeeds is a module that never
upgrades, quietly, until somebody reads the report — which is an argument for
[ADR 0134](0134-the-mesh-says-what-it-applied.md) rather than against this.
**A module's resources must be ordered within the module for the gate to mean anything.** They already
are: the mesh composes a module's resources in the order its manifest declares them, and its own
workload comes after the files it reads.
## How this is checked
- **A failed step stops its module and nothing else.** A test with two modules: the one whose step
failed does not start its workload, the other starts, and the error still says the failure gated
something. It fails against the previous behaviour, which is how it was written.
- **An action still stops the machine.** The existing test for a failed action is unchanged, and a step
with no module in its identity — which is what genesis carries — takes the same path.
- **The report names what was skipped.** Asserted in the same test, because a gate nobody can see is
indistinguishable from a module that had nothing to do.
## References
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the step this narrows
- [ADR 0135](0135-a-module-version-prepares-its-state-before-it-runs.md) — what made the reach reachable
- [issue 011](../04-ISSUES/011-one-broken-module-blocks-every-other/00-report.md) — the same fault, removed once already
- [ADR 0134](0134-the-mesh-says-what-it-applied.md) — how a module left behind becomes visible
- mesh-host `internal/apply` — the loop whose own comment argued this case for every other shape
@@ -0,0 +1,117 @@
---
topic: what runs on it
status: superseded
date: 2026-09-28
deciders: jochen
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
reconstructed: false
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
---
# 137. A machine says which networks it routes
## Context
The filter the mesh derives denies forwarding by default, because without a forward chain it says
nothing about a container's published port
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
To keep a machine's own containers working it then allows two ranges: the container runtime's
default bridge pool, and the pool its compose files are given. Those two are named in the
controller's code, with a comment saying what the gap is:
> A machine whose runtime is configured with something else needs this to say so — which is a thing
> the mesh cannot derive and a reason this list is named here rather than computed.
**There was no way to say so.** The list was a constant. A machine whose guests live anywhere else
was filtered by a rule that looked deliberate and was a guess.
**Measured, on the day a workstation was converged.** Flipping it cut egress for five of its
container networks at once, and for every network its test beds create — the beds allocate a fresh
range per run, from a pool neither default covers. Nothing reported a fault. The containers could
not reach anything, the machine went on reporting that it had applied what it was told, and the
converge preview had said nothing about it either, because the preview lists what *listens* and
routing is not a listener.
**And two questions, not one.** A guest also asks its host for an address and for names. Both arrive
at the input chain, where nothing declared them, so denying by default left the guests of a routed
network with no address and no resolution — which is not a closed port but a network that does not
function, asked for by this machine's own guest.
**Why the machine cannot simply be read.** A test bed creates its bridge while it runs, between one
declaration and the next, so a filter derived from what the machine last reported would be correct
only for the networks that already existed when it was composed. A declared range covers the ones
that do not exist yet.
## Decision
**A machine says which networks it routes for what it hosts, and the filter forwards them.** A
node-level fact, beside the node's public domain
([ADR 0066](0066-public-routing-is-name-agnostic.md)) and for the same reason: the
machine routes them, and the module that loads the filter holds a seat and may be replaced.
**Added to the runtime's defaults, never replacing them.** A machine that names one range has not
stopped hosting whatever was already on the runtime's own pools, and replacing would trade one
silent breakage for another.
**Their guests keep address and name service.** For a network that was named, the input chain admits
that network's own DHCP and DNS, and nothing else: everything else a guest might want from its host
is a port somebody declares, like every other port on this machine.
**Said in CIDR form and checked when it is said.** An entry that does not parse is a line nftables
refuses, and a refused ruleset is a machine filtering nothing while its unit reports a fault — so
the refusal happens where a person can read it, not on the machine.
**A machine that says nothing is filtered exactly as before.** Every machine already converged is
untouched by this.
## Options considered
1. **Leave it constant and edit the code per installation.** Rejected: the value is a property of
one machine, the code is the whole mesh's, and the two ranges as they stand describe a machine
whose runtime was left at its defaults. It is also how this got here.
2. **Derive it from what the machine reports.** Rejected as insufficient, not as wrong: it cannot
cover a network created between two declarations, which is precisely the case that was broken. It
would also make the filter follow whatever appeared on the machine, which is a firewall that
widens itself.
3. **A per-node setting on the module that loads the filter.** Rejected: the machine routes the
networks. The filter module holds a node-scoped seat and is meant to be replaceable, and a
replacement must not lose the machine's own truth.
4. **Replace the defaults with what is said.** Rejected: see the decision. The first machine to name
its bed range would lose its containers.
5. **Admit all input from a routed network, not only address and name service.** Rejected: that is
every port on the machine open to anything it hosts, which is the derivation abandoned.
## Consequences
**The converge preview says what a machine routes**, including when it routes nothing but the
defaults, with the command that changes it. The preview's own sentence about traffic it cannot
preview stays, because a tunnel and the found firewall's NAT are still not previewable.
**A machine whose guests are already broken by an earlier flip is fixed by saying its networks and
pushing**, with no flip to undo.
**The list is one more thing that can be wrong and stale.** A range removed from the machine and
left here keeps forwarding for a network that no longer exists, which admits nothing, because there
is no guest on it to admit. That is the safe direction of being out of date.
## How this is checked
- **What a machine says it routes is forwarded, and its guests keep address and name service.** A
test renders a ruleset for a machine that names one range and asserts both chains, per chain body
so a line in the wrong chain cannot pass it. It fails against the previous behaviour, which is how
it was written.
- **The runtime's own defaults survive naming a range.** Asserted in the same test.
- **A machine that names nothing renders byte-identically to one that names nil**, so every machine
already behind this filter is untouched.
- **Each family is matched in its own syntax.** A test with one v4 and one v6 network asserts
`ip saddr` and `ip6 saddr`, because one set holding both is a syntax error and a ruleset that does
not load is a machine filtering nothing.
- **An entry that is not a network is refused where it is said**, by the parse in the setter.
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the derived filter this completes
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the precedent for a node-level fact
- [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md) — why there is a forward chain at all
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the measurement that produced this
- mesh-controller `internal/catalogue/filtering.go` — the constant whose own comment named this gap
@@ -0,0 +1,154 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
---
# 138. An assignment binds an endpoint and says how far it reaches
## Context
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) settled that a machine's
packet filter is derived from what its modules declare they listen on, and that the `from` of a
listen "is the whole of public-versus-internal". That was true of the packet filter, and it turned
out to be true of nothing else.
Reachability is now settled three times, in three places, by three mechanisms that cannot disagree
out loud ([issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md)):
- **The filter** reads a listen's source, and a per-node setting may override it. That setting has
exactly one caller in the control plane — the function that builds the node's rules.
- **The names** come from a route contribution, which names a label and a port and says nothing
about reach. The reverse proxy composes a **public** name and an **internal** name for every route
it is given, because it can.
- **The certificate authority** follows from which names exist. Measured on the control-node: an
identity provider carries a public certificate valid 90 days and an internal one valid 24 hours and
renewed daily. No assignment asked for either.
So *this endpoint must not be public* cannot be written. It is therefore enforced by nothing, while a
public certificate for that very name is obtained automatically — the fault
[how-we-build.md](../00-META/how-we-build.md) names, an unenforced rule being indistinguishable from
a wrong one, with the additional cost that the wrong thing is done eagerly.
And a port that is not routed cannot be spoken about at all beyond the filter. The forge serves git
over ssh; that endpoint has no name, no certificate and no way to be called public except a key only
the filter reads.
**Two per-node settings already exist and are half of this.** One gives a module's declared port a
machine port. One overrides a declared port's source. They key on port numbers, so nothing ties a
port to the route that serves it: a route contribution names a port too, and the two are equal only
by coincidence.
**Where this belongs is already decided.** [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)
says a module's configuration is its assignments. Whether the forge answers git-over-ssh from the
public internet is a fact about one installation and one machine, not a property of the software —
and [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) already refuses an
installation's decisions in a definition.
## Considered Options
1. **Leave reach in the manifest, as `from` today.** Rejected: it is an installation's decision
written into the definition, and it cannot differ between two machines running the same module —
which is exactly the case the forge presents.
2. **Extend the existing source override to the names and the certificate, without naming
endpoints.** Rejected: it keys on a port number. A module's route contribution names a port as
well, and nothing says the two are the same thing, so one statement cannot be made to reach all
three mechanisms. Naming the endpoint is what makes that possible.
3. **Derive reach from whether the node has a public domain recorded.** Rejected: that is a property
of the machine, and two endpoints on one machine differ — a database and a web front end on the
same host.
4. **A fourth reach for "public name, internal authority"** — a name that resolves publicly and must
not appear in a public issuance log, obtained by DNS-01. Deferred, not rejected: it is a real case
and it is a question about which challenge an authority uses, not about how far an endpoint
reaches. Left to the certificate work as an open question.
5. **Make the manifest silent on reach and require every assignment to state it.** Rejected for the
transition: every endpoint reachable today would close until an assignment named it, which is a
flag day across the whole catalogue.
## Decision
**A module declares named endpoints.** An endpoint is one port the module serves, with a name the
module chooses, its protocol, and what it is for. A route contribution **names the endpoint it
routes** rather than repeating a port number. The manifest says what the module serves and what it
would serve it to by default; it does not say what this installation does with it.
**An assignment binds each endpoint and says how far it reaches.** Per node: the machine port the
endpoint is published on, and its **reach** — one of `internal`, `public` or `both`. An assignment
that states nothing keeps the manifest's default, so no machine changes until an assignment says so.
**Reach means all three mechanisms at once, and is the only thing that decides them.**
- `internal` — the filter opens the machine port to the private network; the proxy serves the
internal name and not the public one; the certificate comes from the mesh's own authority.
- `public` — the filter opens it to anywhere; the proxy serves the public name; the certificate
comes from the public authority.
- `both` — both names, each from its own authority, and the filter opens to anywhere.
**An endpoint that is not routed is reached but never named.** An endpoint with no route contribution
yields filter rules and nothing else: no name is composed and no certificate is requested. Git over
ssh is that case, and it is the case the model could not express.
**The authority stops being chosen by which names happen to exist.** The proxy composes the names the
assignments asked for, and asks each name's own authority for it. A name nobody asked for is not
composed, so it is not certified.
**The two existing settings are this, completed.** The per-node port mapping becomes the endpoint's
binding. The per-node source override becomes its reach, widened from the filter alone to the names
and the certificate as well.
## Consequences
- **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.**
Every routed module's manifest changes. The word ships one release before any manifest uses it, and
reaches the build machine and the control plane first.
- **One derived value is read by three things** — the filter's rules, the proxy's contributions, the
certificate request — so they can no longer disagree, and a disagreement becomes a refusal at the
assignment rather than a surprise on a machine.
- **A name that must not be public becomes writable, and therefore checkable.** It also gives
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) a
declared answer to read: which endpoints are internal is what says whose root must be installed
where.
- **[Issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
becomes answerable**: the endpoint's assignment names the machine that serves it, which is the fact
the internal name should be composed from.
- **Reach becomes reportable.** The mesh can say, per endpoint, where it is reachable from and which
authority holds its certificate — neither of which `status` can say today.
- **This narrows [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md).** Its
decision stands: the firewall is derived and host-applied, not a provider. What no longer holds is
that a listen's `from` is the whole of public-versus-internal; it is the filter's share of a
statement that also governs names and certificates.
- **What got harder:** every endpoint needs a name, including a module that serves exactly one port
and had no reason to name it. And an installation that wants a module public must now say so on the
assignment rather than inheriting it from the definition, which is more to say and the reason it is
right.
## How it is checked
- **One module, two endpoints, different reach.** A module declaring an internal endpoint and a
public one renders a filter opening one to the private network and one to anywhere, asserted per
chain body so a rule in the wrong chain cannot pass.
- **The names follow the reach.** The same module's routed endpoint composes the internal name only
when internal, the public name only when public, and both when both — and a certificate is
requested from the matching authority for each name composed and for no other. This fails against
the previous behaviour, where both names and both certificates are always composed, which is how
it is written.
- **An unrouted endpoint is filtered and never named.** Asserted for an endpoint with reach and no
route contribution: rules rendered, no contribution, no certificate request.
- **An assignment naming an endpoint the module does not declare is refused where it is said**, as is
a reach that is not one of the three — before it reaches a machine, because a ruleset that does not
load is a machine filtering nothing.
- **An assignment that states nothing renders byte-identically to today**, so every machine already
converged is untouched until its assignment says otherwise.
## References
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — narrowed here
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) — where reach belongs
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the public name this composes
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why reach is not a definition's
- [issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md) — the measurement
- [issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md),
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md)
@@ -0,0 +1,137 @@
---
topic: what runs on it
status: superseded
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
---
# 139. A network is forwarded because a module declared it
## Context
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md), decided the same week, gave a machine a
way to say which networks it routes for its guests. It was written because the derived filter's
forward chain allowed two ranges named as constants in the control plane's source — the container
runtime's bridge pool, and part of the pool its compose files are given — with a comment admitting
the gap: *a machine whose runtime is configured with something else needs this to say so, which is a
thing the mesh cannot derive.*
**It can be derived, and from the right place.** Measured on the last machine still to be converged
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)): twenty-one
container networks, nine inside the runtime's bridge pool, twelve in the other private range, and six
of those outside the constant's lower bound — so the flip would have cut their guests off exactly as
it did on the workstation that produced 0137.
Naming a range to cover the six is what 0137 provides for, and it is the wrong instrument. Of those
six networks, **four are networks the mesh's own modules declare**, present as network resources in
the node's plan and created by the host because a module asked for them. **Two are the predecessor's
leftovers** — compose networks of services the mesh does not run. Any range wide enough to keep the
four forwards the two as well: a firewall widened by hand to protect networks that should not exist.
The mesh already knows which of the twenty-one are its own, because it made them.
**And the node's configuration is meant to follow the modules assigned to it.** That is the mesh's
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
from what runs there ([ADR 0005](0005-the-node-host.md),
[ADR 0010](0010-delivery.md),
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)). The forward chain is the
one derived thing that consults a constant and a list a person types.
## Considered Options
1. **Keep 0137 as it stands** — two constants plus a named list. Rejected: the list is written in
addresses, and addresses are what the runtime allocates, so the only entry safe enough to keep a
machine working is wider than the truth. It cannot distinguish a network the mesh made from one
left behind, which is the distinction that decides whether forwarding it is correct.
2. **Derive it from what the machine reports.** Still rejected, on 0137's own grounds: a test bed
creates its bridge between one declaration and the next, and a filter that follows whatever
appeared on a machine is a firewall that widens itself. **This decision is not that** — see below.
3. **Have the control plane allocate each module network's range from a pool it owns,** so it can
render the address itself. Rejected: more machinery for no gain. The runtime already allocates and
the host already knows, and taking allocation over means the mesh owning an address space it has no
other reason to own.
4. **Have each module declare its network's range.** Rejected by
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a definition names no address,
and the same definition runs on machines whose runtimes have allocated differently.
## Decision
**A network is forwarded because a module declared it.** Per node, the forward chain forwards the
networks of the modules assigned there, and by default nothing else. A module unassigned stops being
forwarded at the next reconcile.
**The host resolves a declared network to its addresses.** A network resource carries a name; the
runtime allocates the subnet when the network is created. So the control plane declares *forward the
networks these modules asked for* and the host — which made them, and already resolves a container by
its name — renders the addresses. [ADR 0005](0005-the-node-host.md) holds: the host applies, it does
not decide.
**Deriving from the declaration is not deriving from the machine.** Both of 0137's objections fall
away. The set is known before the network exists, because a module declared it, so a network created
between two declarations is already in the one that asked for it. And it cannot widen itself: a
network nobody declared is never forwarded, however it appeared on the machine.
**The runtime's own default bridge is forwarded, from what the runtime reports.** Containers that name
no module network attach to it, and it belongs to the runtime rather than to any module — so the host
renders it from what the runtime says, not from a range named in the control plane. The constants go.
**What a machine says is for guests no module declares.** A test bed is not a module and its range is
not a module's; that is the case 0137's mechanism is for, and it keeps it — added to the derived set,
never replacing it, as 0137 decided. Narrowed to that, it is named for it.
**Their guests keep address and name service**, per declared network, unchanged from
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md): the input chain admits that network's own
DHCP and DNS and nothing else.
## Consequences
- **The two constants are removed**, and with them the class of fault that a machine's guests depend
on a range that describes some other machine.
- **This is a behaviour change, not a refactor.** On the machine measured, the derived set and the
constant do not cover the same ground — that is the whole reason for the record. A machine whose
module networks happen to fall inside the old ranges renders the same rules.
- **A range that exists only to keep a leftover alive becomes visible as such**, because it will not
be in the derived set and has to be said out loud to survive.
- **`node networks` narrows** to guests no module declares, and the preview says which of a machine's
networks are the mesh's and which are not, so the difference is readable before a flip rather than
after.
- **A module's declaration gains nothing.** It already declares its network; what changes is that the
filter reads it.
- **What got harder:** the host renders part of the forward chain from what it created, so the
control plane no longer holds the whole rule set as text. The rule the mesh states is the set of
networks; the addresses are the machine's.
## How it is checked
- **Only declared networks are forwarded.** A node with two modules that declare networks renders
forward rules for exactly those two, and none for a third network present on the machine that no
module declared. This fails against the previous behaviour, which forwards by range and cannot tell
them apart, and that is how it is written.
- **Unassigning a module removes its network's rule** at the next reconcile, asserted on the rendered
chain rather than on the intent.
- **Guests of a declared network keep address and name service**, asserted per chain body so a line in
the wrong chain cannot pass — carried from 0137.
- **The runtime's own default bridge comes from the runtime**, asserted by rendering for a runtime
whose default bridge is somewhere other than the range the constant named.
- **A machine that names a range for guests no module declares still gets it**, added to the derived
set and not replacing it.
- **Each family is matched in its own syntax**, carried from 0137: one set holding both is a syntax
error, and a ruleset that does not load is a machine filtering nothing while its unit reports a
fault.
## References
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md) — narrowed here; its mechanism keeps the
case it is right for
- [ADR 0005](0005-the-node-host.md) — the host applies; the addresses are the machine's
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the filter is derived from
what runs there
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a module does not name its
range
- [issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md) — the
measurement
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the
breakage that produced 0137
@@ -0,0 +1,146 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
supersedes:
- 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
- 02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md
---
# 140. The filter constrains what arrives from outside, and says nothing about a machine's own guests
## Context
The filter the mesh derives blocks traffic passing *through* a machine unless something allows it,
because a container's published port is traffic passing through rather than traffic arriving at the
machine itself ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md),
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
Having blocked all of it, the filter then had to let the machine's own containers reach outward again.
It does that by listing the address ranges those containers sit on.
As rendered on a converged workstation today:
```
policy drop
ct state established,related accept
ip saddr 172.16.0.0/12 accept
ip saddr 192.168.128.0/17 accept
ip saddr 10.0.0.0/8 accept
ip saddr 192.168.16.0/20 accept
... four more
```
Two of those ranges were constants in the control plane's source. The rest were typed by the operator
after [ADR 0137](0137-a-machine-says-which-networks-it-routes.md), which existed to make the typing
possible, because converging that workstation had cut every one of its containers off from the
internet and nothing reported a fault
([issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md)).
**The list is the mistake, not its contents.** Every attempt to make it correct fails the same way.
A constant describes one machine. A typed range goes stale, and cannot tell a network the mesh made
from one a predecessor left behind — measured on the control-node, where six such ranges fall outside
the constants and two of the six belong to services the mesh does not run
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)).
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) tried to generate the same
list from the modules and put half the rule set on the machine to do it. Three records, one list, and
the list should not exist.
**Because the mesh has no policy about a container reaching outward.** What the filter is for is
stated in [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md): which port is open,
and to whom. That is about what arrives. A container of this machine's own opening a connection to
something else is not a port being opened to anybody, and enumerating the addresses it might do so
from is bookkeeping about the machine's internal plumbing, which the mesh neither owns nor can know.
**The system being replaced never had this fault, and its rule says why.** The chain still protecting
the control-node applies only to traffic arriving on that machine's outward link, and leaves
everything else alone. The mesh's filter dropped that distinction and replaced it with a list of
addresses.
## Considered Options
1. **Keep the list and generate it better** — from the modules' declared networks, or from what the
machine reports. Rejected: [ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md)
is that, and it puts part of the rule set on the machine, which makes the rule set partly the
machine's and the derivation advisory.
2. **Name the guest links instead of their addresses, and allow only those.** Rejected as more than is
needed: it fails in the safe direction, but it is still a list that has to keep up with the
machine, and the thing it protects against — a container reaching outward — is not a thing the mesh
has a position on.
3. **Do not block traffic passing through at all.** Rejected: that is
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md),
where a published port was reachable from anywhere because no rule mentioned it.
4. **Constrain what arrives from outside, and nothing else.** Adopted.
## Decision
**The filter constrains traffic arriving from outside the machine, and says nothing about traffic that
did not.** Traffic passing through the machine is allowed unless it arrived on one of the machine's
outward links, in which case it is allowed only where a declared endpoint's reach admits it
([ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)). A container of this
machine's own reaching anywhere is not filtered, because the mesh has no position on it.
**A machine says which of its links face outside.** One node-level fact, reported by the machine the
way it already reports the kind of firewall it found and the tunnel it carries — not a setting, not a
list of addresses, and not something anybody types. It does not change when a module is added or
removed, which is what separates it from the list it replaces.
**A machine that has reported no outward link is sent no filter.** Rendering a rule around a link
whose name is not known produces a rule set that does not load, which is a machine filtering nothing
while its unit reports success. The refusal happens in the control plane, where a person reads it, and
the machine keeps the filter it already has.
**No addresses of the machine's own networks appear in the filter.** The two constants are removed and
`node networks` is removed with them, along with everything any machine was told to say through it.
Ports continue to follow the modules exactly as before: a module assigned to a machine opens the port
its assignment says it reaches on, and nothing about a network is said anywhere.
## Consequences
- **Three records collapse into one rule.** 0137 and 0139 are superseded. What 0137 was right about —
that converging a machine had silently cut off its own containers, and that nothing previewed it — is
answered by removing the cause rather than by giving the operator a way to compensate for it.
- **Every machine already converged loses its declared ranges and keeps working**, because the traffic
those ranges allowed is now allowed by not having arrived from outside. The workstation's five ranges
and the laptop's one are deleted rather than migrated.
- **A machine's test beds stop being a special case.** A bed's network is created while the machine
runs and was the case no list could cover; it is now covered by not being mentioned.
- **A new fact travels in the report**, and the control plane refuses to compose a filter without it,
so the order of the roll-out matters: the machines report before the control plane depends on it.
- **A machine with more than one outward link says so**, and a machine that acquires one while the mesh
is not looking is treated as internal until its next report. That window is the cost of this shape;
it is bounded by the report interval, and it exists on machines whose outward link changes, which
are the machines with nothing published to the outside.
- **What got harder:** nothing in the declaration, and one more thing a machine must be able to work
out about itself. A machine that cannot say which link faces outside cannot be given a filter.
## How it is checked
- **A machine's own container reaches outward with no network named anywhere.** A bed converges a
machine carrying containers on several networks, none of them mentioned in any setting, and each
reaches out afterwards. This fails against the previous behaviour, where the same flip cut them off,
and that is how it is written.
- **A port declared reachable from outside is reachable; one that is not, is not.** Probed from off the
machine's private network, for a published port and for an undeclared one, before and after the flip.
- **A network created after the filter was composed needs no new filter.** A network is made on the
machine after its last declaration and a container on it reaches out, with nothing re-sent.
- **No address of a machine's own networks appears in a rendered filter**, asserted on the text so a
range cannot creep back in.
- **A machine that reports no outward link is sent no filter, and the refusal names it** — asserted in
the control plane, and that the machine's existing filter is left alone.
- **A machine reporting two outward links has both constrained**, asserted per chain body so a rule
covering one and not the other cannot pass.
## References
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — what the filter is for
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — what admits traffic
arriving from outside
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — why traffic passing through is
filtered at all
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md),
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) — superseded here
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md),
[issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)
@@ -0,0 +1,154 @@
---
topic: what runs on it
status: accepted
date: 2026-09-29
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0005-the-node-host.md
---
# 141. The host delivers its own successor, and versions live side by side
## Context
[Issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md). A
merge builds every changed module and the control plane — which is itself a module — and the result
reaches the machines running it with nobody asking. The host is the exception: it is not a build
target, no declaration delivers it, and every machine in this mesh runs a byte-identical binary that
somebody built on a workstation and copied out.
The half that *recovers* from a bad host exists. `internal/upgrade` can tell that the executable this
process started from was replaced on disk, and it records which version last completed a reconcile.
The launcher counts consecutive failed starts, calls a rollback at the limit, and treats a clean exit
as the host standing aside so that the next loop runs whatever is on disk now. That supervision is
complete and correct.
Two things make it dead code:
- **`Replaced()` is called by nothing but its own tests.** Nothing tells the running host that a
successor is waiting.
- **The rollback resolves a version through the machine's package manager** — `pacman -U` from the
package cache. No machine here has the host installed as a package, so the recovery cannot run on
any of them; and being written in one package manager's terms, it cannot run on two of the three
operating systems the host is built for — [ADR 0005](0005-the-node-host.md) builds one binary per
operating system, pinned at link time.
**The record already points at the answer.** What is kept is a *version*, not a path. Keeping a
version is only useful to something that can choose between versions present on the machine, which is
what the package manager was being asked to do. The versions can simply be on disk.
## Considered Options
1. **Deliver the host as a package, as the rollback assumes.** Rejected: it needs a package built and
a repository trusted per operating system, three of each, and the existing `package` resource
asserts presence and deliberately never a version — "version is the package manager's business and
the mesh does not hold a second opinion about it" — so it cannot ask for a particular host anyway.
Heaviest of the three and the only one that is different on every machine.
2. **Write the new binary over the running one.** Rejected on a fact: a running executable cannot be
truncated, and `archive` opens what it unpacks with `O_TRUNC`. It could be made to write and
rename, which is better hygiene and worth doing for its own sake, but it buys nothing here that
option 3 does not, and it leaves rollback with nowhere to go back to.
3. **Versions side by side; the newest retires the old.** Adopted.
## Decision
**A host version is delivered as an archive into a directory named for it, and never over a running
one.** The declaration names it like any other archive — fetched by digest, the digest checked before
anything is unpacked. Nothing new travels, no new resource kind, and no change to how archives are
applied, because the path being written is not the path being executed.
**The launcher starts the most recently delivered version.** That is what "the newest" means: the
version whose directory arrived last. It reads no pointer and follows no link — the mesh creates no
links ([ADR 0012](0012-the-mesh-creates-no-symlinks.md)) — and the version is in the path, so nothing
has to be told what is running.
**The running host stands aside for a successor, and only between reconciles.** Finding a newer
version delivered, it finishes the reconcile it is in and exits cleanly. The launcher already reads a
clean exit as exactly this and starts what is on disk now. A host that stood aside mid-apply is the
half-configured machine this project exists to prevent, so the check happens at the boundary and
nowhere else.
**A version that completes a reconcile records itself, and retires what came before it.** The
known-good record is written as it is today. Then versions older than the one before the running one
are removed: the running version and its predecessor are kept, which is exactly what a rollback
needs, and nothing else accumulates.
**Rollback starts the previous version instead of reinstalling a package.** At the failure limit the
launcher pins the known-good version and starts that, once. The second failure is still a different
diagnosis — the previously working version does not run either, so it is the machine and not the
binary — and the halt is unchanged. No package manager, no package cache, and the same script on every
operating system.
**A machine says which host version it is running,** on the report it already sends, beside the other
facts it states about itself. Without it nothing can say a machine is behind, so "every machine
current with its source" cannot include the host.
## Progressive insight — 2026-09-29, the same day
**The delivery is not "nothing new", and this record said it was.** The decision above stands and is
built: versions side by side, the newest runs, the running host stands aside between reconciles, a
completed reconcile retires what is older than the predecessor, rollback picks a directory. What was
wrong was a claim about how a version reaches a machine. The paragraph on delivery said the
declaration "names it like any other archive… nothing new travels, no new resource kind"; the second
half is true and the first is not, because two things the delivery needs do not exist:
- **Nothing can compile it.** A `bundle` artifact is compiled by a closed list of toolchains —
typescript and python — whose own comment says adding a language is a decision, because a language
used by *modules* needs an SDK carrying the broker client, the event envelope and tool serving. The
host uses none of that: it is what applies modules, not one of them. So the obligation that list
warns about attaches to a module written in a language, not to the language being buildable, and
the control plane — also written in Go — is built as an image from a Dockerfile rather than through
a toolchain at all.
- **A version cannot reach the path.** An `archive` resource names a fixed path in the manifest, and
nothing interpolates the built version into it, so nothing can ask for
`…/versions/<version>/`.
Neither changes what was decided, which options were weighed, or any consequence: the shape is
unaffected and the host half is merged and tested. What it changes is the cost, which this record
understated as none. The remaining work is a way to build the host and a way to name a version in a
path, and until both exist nothing delivers a version and every machine takes the fallback — which is
what every machine does today.
## Consequences
- **The host becomes a build target and a module** — a module whose resource is the next host, applied
by the host that is running. The bootstrap is not circular because the two are different versions in
different directories.
- **Rollback becomes usable on every machine**, having been usable on none. It also stops being
written in one operating system's terms.
- **One copy by hand remains, once.** The first host that understands versioned directories cannot be
fetched by a host that does not. That copy is the last, and it is the honest cost of the change
rather than a step in the design.
- **Two versions occupy disk instead of one.** About nine megabytes. The predecessor is the price of a
rollback that does not depend on a cache somebody else may clean.
- **What got harder:** a host must now be able to find its own successor and to judge when it is safe
to stand aside. Both are between reconciles, which is the only moment the host is not mid-change.
- **A machine that is never told a newer version keeps running what it has**, indefinitely and
visibly, because its report says which version that is.
## How it is checked
- **A delivered version is run, and the old one is not.** A bed delivers a second version to a machine
running the first; the host exits between reconciles, the launcher starts the new one, and the
machine reports the new version. This fails against the previous behaviour, where nothing notices a
delivered version at all.
- **It stands aside between reconciles and never inside one.** Asserted by delivering a version while
an apply is in flight: the apply completes, and the exit follows it.
- **A version that will not start is rolled back to its predecessor, once**, and the second failure
halts with the machine named rather than the binary — asserted with no package manager involved.
- **A completed reconcile retires what is older than the predecessor**, and never the predecessor
itself, because that is what a rollback needs. Asserted on the directory afterwards.
- **The report names the running version**, asserted end to end rather than on the function that reads
it, since the point is that the control plane can tell a machine is behind.
- **The launcher picks the newest delivered version** with no pointer file and no link, asserted by
delivering two and checking which runs.
## References
- [ADR 0005](0005-the-node-host.md) — the host, and what its supervision is for
- [ADR 0010](0010-delivery.md) — a declaration is owned resources; this adds no kind to it
- [ADR 0012](0012-the-mesh-creates-no-symlinks.md) — why the version is in the path
- [ADR 0005](0005-the-node-host.md), *it is built per operating system* — why a rollback written in
one package manager's terms was wrong for two of three
- [issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md) — the
measurement
@@ -0,0 +1,158 @@
---
topic: the mesh
status: accepted
date: 2026-09-29
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
---
# 142. The mesh delivers its own components as binaries, not as container images
## Context
Measured on the control-node, 2026-09-29:
| what | how it runs | publishes |
|---|---|---|
| host | a binary on the machine | — |
| controller, catalogue, builder, vault | containers | nothing |
| store, registry, broker | containers | ports |
**The mesh's own software is delivered two ways, and the difference is not a property of the
software.** The host and the controller are both written in the same language, both the mesh's own,
both doing the mesh's own work. One is an image fetched from a registry. The other is a file somebody
copied to four machines, owned by no package, built by nothing
([issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md)).
**The reason is not a judgement about either, it is that images are the only delivery that works.**
There is no way to put a binary on a machine. The host is hand-copied because of that, and the
controller is an image because of that. Neither was chosen on its merits.
What it costs, all of it measured rather than argued:
- **Genesis must raise a container runtime before the control plane can exist.** The bundle carries
three images and one of them is the controller, *"in the bundle for the same reason they are: there
is nothing to fetch it with yet"*
([design 07](../03-DESIGN/01-to-be/07-the-foundation.md)). So the hardest moment in the mesh's life
has a prerequisite that the thing being started does not need.
- **Updating the control plane depends on the control plane.** Its image is fetched from the registry,
which is a container the controller manages.
- **A change to the host cannot be rolled out at all.** Every machine here runs a byte-identical
hand-copied binary. A change merged yesterday reached none of them.
- **Compiling the language the mesh is written in is not a capability of the builder.** The bundle
toolchains are typescript — real, with a registered base module — and python, which is named in the
list and absent from the catalogue. The controller is built as an image from a Dockerfile, which is
the per-repository incantation the bundle toolchain exists to abolish
([design 18](../03-DESIGN/01-to-be/18-building-a-module.md)).
The half that *receives* a binary safely is already built and tested
([ADR 0141](0141-the-host-delivers-its-own-successor.md)): versions side by side in directories named
for them, the newest run, the running one standing aside between reconciles, retirement keeping the
predecessor, and a rollback that chooses a directory. What is missing is everything that puts one
there.
## Considered Options
1. **Leave it as it is.** Rejected: it is not a design, it is the reach of one mechanism. And it is
what makes a host change undeliverable.
2. **Containerise the host too**, so everything is delivered one way. Rejected: the host is what
starts the container runtime and what applies containers. A host in a container is the bootstrap
problem made total, and the machine would have no way back from a bad one.
3. **Deliver the mesh's components as operating-system packages.** Rejected for the reason
[ADR 0141](0141-the-host-delivers-its-own-successor.md) rejected it for the host: a package and a
trusted repository per operating system, three of each, and the `package` resource asserts presence
and deliberately never a version.
4. **Binaries for the mesh's own components, containers for third-party software.** Adopted.
## Decision
**The mesh's own components are delivered as binaries on the machine.** The host, the controller, the
catalogue, the builder, the vault — the software this project writes. They are delivered by the
mechanism [ADR 0141](0141-the-host-delivers-its-own-successor.md) built: an archive, fetched by
digest, unpacked into a directory named for its version, with the running one standing aside between
reconciles and a rollback that chooses the predecessor.
**Third-party software stays a container.** The store, the registry, the broker. They are somebody
else's build, they are already adopted as modules
([ADR 0078](0078-the-store-and-broker-are-modules.md)), and an image is the right way to carry
somebody else's software. **The container runtime remains required** — modules use it — so this
removes a dependency from the control plane, not from the machine.
**The builder compiles the languages the mesh is written in.** A toolchain for Go, with a base module
providing the compiler, exactly as typescript has. The obligation the toolchain list warns about — an
SDK carrying the broker client, the envelope and tool serving — attaches to a *module* written in a
language, not to the language being compilable. None of these components is a module in that sense;
the host is what applies modules.
**An artifact says what it targets.** A compiled binary is per operating system, pinned at link time
([ADR 0005](0005-the-node-host.md)), and a toolchain deliberately takes nothing from the module,
because anything a module could override there it would be writing a Dockerfile to override. So the
target is a property of the artifact rather than of the recipe, and one artifact declared per target
is one build each.
**A component's version comes from where it sits, not from its linker.** It is unpacked into a
directory named for its version, so it can read its own version from its path. The stamp goes, and
with it the need for a build to know what it will be called.
**Genesis carries a binary reference where it carried an image reference.** The principle does not
change — the bundle names a thing by digest and the host fetches it, pinned because nothing can
resolve a version when no mesh exists — and the container runtime stops being a prerequisite for the
control plane. It stays a prerequisite for the store and the broker, which is where it belongs.
**The order is staged, and each step stands alone.** Compiling Go; an artifact naming its target;
delivering a binary; the host as the first component delivered; the controller, catalogue, builder and
vault out of their containers; genesis last. Genesis is last for the reason it is always last: it
matters for a machine nobody has yet, and every earlier step is provable on a mesh that exists.
## Consequences
- **One delivery for the mesh's own software**, so a change to the host ships the way a change to the
controller does, and neither is copied by hand.
- **The control plane stops depending on a container runtime and on its own registry.** Both remain on
the machine for other reasons; neither gates the control plane's own life any more.
- **`Replaced()`, the known-good record and the launcher's rollback stop being dead code.** They were
written for this and have been called by nothing but their tests.
- **Four more components gain a rollback they do not have.** Today a bad controller image is recovered
by an operator; under this it is recovered the way a bad host is.
- **Two versions of each component occupy disk.** Around nine megabytes each. The predecessor is what a
rollback needs.
- **Genesis gets smaller, not larger.** One fewer image to carry and one fewer runtime to raise before
the control plane.
- **This does not make the components smaller or simpler.** They are the same programs; what changes is
how they arrive. A reader expecting the containers to have been hiding complexity will not find any.
- **What got harder:** the builder gains a language, artifacts gain a target, and the mesh gains a
second kind of thing it must deliver correctly — one where getting it wrong takes the control plane
down rather than a module. That is why the host is first: it is the component whose recovery is
already built and tested.
## How it is checked
- **A component is delivered and runs, with nothing copied by hand.** A bed builds the host from its
repository, delivers it to a machine running an older one, and the machine reports the new version.
This fails today at the first step, because nothing builds it.
- **Each target is built once and only the matching one is delivered.** Asserted by declaring an
artifact per operating system and checking that a machine is offered the one it can run — a host
built for another is what ADR 0005's link-time pin exists to refuse.
- **A component reads its version from its path**, asserted by unpacking the same bytes into two
differently named directories and seeing each report its own.
- **A bad component is rolled back without an operator**, for the host first: a version that will not
start is replaced by its predecessor once, and the second failure halts naming the machine.
- **The control plane comes up with no registry reachable**, which is the dependency this removes —
asserted by raising it with the registry stopped.
- **Genesis raises a control plane with no container runtime running**, and raises the store and the
broker afterwards. Last, and on a machine with nothing on it.
- **A published port count that does not change.** The mesh's own components publish nothing today, so
moving them out of containers must not open anything — asserted on the machine's reachable set before
and after, which the converge preview already reads.
## References
- [ADR 0141](0141-the-host-delivers-its-own-successor.md) — the receiving half, already built
- [ADR 0005](0005-the-node-host.md) — the host, its supervision, and one binary per operating system
- [ADR 0078](0078-the-store-and-broker-are-modules.md) — why third-party software stays a container
- [ADR 0006](0006-the-substrate-and-the-control-plane.md) — what genesis must raise, and in what order
- [issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md) — the
measurement that started this
- [design 07](../03-DESIGN/01-to-be/07-the-foundation.md) — the bundle's three images, one of them the
controller
+14 -2
View File
@@ -135,10 +135,14 @@ python3 00-META/checks/index.py fail if stale
- **0116** — [The bus is built in five steps, and the protocol moves with it](0116-the-bus-is-built-in-five-steps.md)
- **0119** — [A taken tunnel's predecessor is retired once the take is proven](0119-a-taken-tunnels-predecessor-is-retired.md)
- **0125** — [The bus is the only broker](0125-the-bus-is-the-only-broker.md) *(superseded)*
- **0127** — [AMQP is a provision, not the bus](0127-amqp-is-a-provision-not-the-bus.md)
- **0127** — [AMQP is a provision, not the bus](0127-amqp-is-a-provision-not-the-bus.md) *(superseded)*
- **0128** — [The mesh bus is required, not ambient](0128-the-mesh-bus-is-required-not-ambient.md)
- **0129** — [A seat carries the protocol of its role](0129-a-seat-carries-the-protocol-of-its-role.md)
- **0130** — [The predecessor is ending, and its broker goes with it](0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md)
- **0131** — [Everything on the mesh speaks to the broker seat, and AMQP is not a provision](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)
- **0132** — [A seat carries the tools its holder must serve](0132-a-seat-carries-the-tools-its-holder-must-serve.md)
- **0134** — [The mesh says what it applied](0134-the-mesh-says-what-it-applied.md)
- **0142** — [The mesh delivers its own components as binaries, not as container images](0142-the-mesh-delivers-its-own-components-as-binaries.md)
### Its tiers, from the bottom up
@@ -202,7 +206,7 @@ python3 00-META/checks/index.py fail if stale
- **0091** — [A mount is declared, and there are three things it can be](0091-a-mount-is-declared-three-ways.md)
- **0099** — [A step that runs once names what it reads, and runs again when it changed](0099-a-step-that-runs-once-names-what-it-reads.md)
- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md)
- **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md) *(proposed)*
- **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md)
- **0113** — [The vault makes every shared secret, a provider makes resources and data, and the mesh carries both](0113-the-vault-makes-every-secret.md) *(proposed)*
- **0114** — [A credential two parties hold rotates over two credentials; one a single party holds rotates in place, staged; and retiring a credential never removes what it reached](0114-a-shared-credential-rotates-over-two-credentials.md) *(proposed)*
- **0115** — [One assignment of a module per node: the module's name is the assignment's identity](0115-one-assignment-of-a-module-per-node.md) *(proposed)*
@@ -211,6 +215,14 @@ python3 00-META/checks/index.py fail if stale
- **0120** — [A roster fact carries its format as a template: the mesh owns the data, the module owns the format](0120-a-roster-fact-carries-its-format-as-a-template.md)
- **0121** — [A system seat is named for its scope, and a module may define its own](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
- **0122** — [A seat is data the controller owns, and a rename is a database update](0122-a-seat-is-data-a-rename-is-a-database-update.md)
- **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) *(superseded)*
- **0135** — [A module version prepares its state before it runs](0135-a-module-version-prepares-its-state-before-it-runs.md)
- **0136** — [A step gates its module, not the machine](0136-a-step-gates-its-module-not-the-machine.md)
- **0137** — [A machine says which networks it routes](0137-a-machine-says-which-networks-it-routes.md) *(superseded)*
- **0138** — [An assignment binds an endpoint and says how far it reaches](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)
- **0139** — [A network is forwarded because a module declared it](0139-a-network-is-forwarded-because-a-module-declared-it.md) *(superseded)*
- **0140** — [The filter constrains what arrives from outside, and says nothing about a machine's own guests](0140-the-filter-constrains-what-arrives-from-outside.md)
- **0141** — [The host delivers its own successor, and versions live side by side](0141-the-host-delivers-its-own-successor.md)
### How it is built
+44 -1
View File
@@ -2,8 +2,9 @@
layer: to-be
status: in-progress
code: [mesh-host]
updated: 2026-09-22
updated: 2026-09-29
decisions:
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
- 02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md
@@ -423,3 +424,45 @@ ignored an instruction and "applied" would be a lie. Applying stays one at a tim
is not. **Checked** by the link's unit tests on the drain, and by the genesis bed's settle wait,
which counts on a node catching up to the newest declaration rather than the oldest.
## The host delivers its own successor
*2026-09-29, from a change to the host that could reach no machine —
[issue 142](../../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md),
settled by [ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md).*
A merge builds every changed module and the control plane, and the result reaches the machines running
it with nobody asking. The host was the exception: not a build target, named by no declaration, and
identical on every machine because somebody had copied it there.
The supervision needed for this was already right. A clean exit from the host means it has stood aside,
and the launcher's next turn runs whatever is on disk. Consecutive failed starts are counted, a
rollback happens at the limit, and a second failure halts with the machine named rather than the binary.
What was missing was smaller than it looked: nothing told the running host a successor was waiting, and
the rollback resolved its known-good *version* through one operating system's package manager, which no
machine here used.
Keeping a version rather than a path was the clue. That is only useful to something that can choose
between versions present on the machine — so the versions live side by side:
- **A version arrives as an archive, in a directory named for it.** The ordinary resource, fetched by
digest and checked before anything is unpacked. The path written is never the path being executed, so
replacing a running binary — which the kernel refuses — never comes up.
- **The launcher starts the most recently delivered version**, reading no pointer and following no
link, because the version is in the path.
- **The running host stands aside between reconciles and never inside one.** Standing aside mid-apply is
the half-configured machine this document exists to prevent.
- **A version that completes a reconcile records itself and retires what is older than its
predecessor.** The predecessor stays, because that is what a rollback needs.
- **Rollback starts that predecessor** instead of reinstalling a package: no package manager, no cache
somebody else may clean, and the same script on every operating system.
- **A machine says which host version it runs**, on the report it already sends, so being behind is
answerable at all.
One copy by hand remains, once: the first host that understands versioned directories cannot be fetched
by a host that does not.
*How it is checked* is stated with the decision — a second version delivered to a running machine is
run and reported; the exit follows an in-flight apply rather than interrupting it; a version that will
not start is rolled back once and the second failure halts; a completed reconcile retires what is older
than the predecessor and never the predecessor; and the newest of two delivered versions is the one
that runs.
+23 -2
View File
@@ -11,8 +11,9 @@ code:
- mesh-catalog modules/postgres
- mesh-catalog modules/lavinmq
- mesh-lab test/integration/mesh.test.ts (a bare machine becomes a mesh)
updated: 2026-09-22
updated: 2026-09-29
decisions:
- 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
- 02-DECISIONS/0088-the-foundation-filters-before-anything-listens.md
- 02-DECISIONS/0004-a-node-and-how-it-joins.md
@@ -314,4 +315,24 @@ of a database and pushed to over the broker. What arrived and what did not is th
**One fault, and it was in the joining.** The token did not say what the mesh calls the machine,
so enrolment needed a flag its own help said it did not — and failed at the broker with an empty
username. Recorded in ADR 0004 as the fifth thing a token carries.
username. Recorded in ADR 0004 as the fifth thing a token carries.
## The mesh's own components arrive as binaries
*2026-09-29 —
[ADR 0142](../../02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md).*
The bundle carries three images and one of them is the controller, *because there is nothing to fetch
it with yet*. That reasoning holds and its conclusion changes: the controller is carried as a **binary**
reference rather than an image reference, pinned by digest exactly as before. Nothing about the bundle's
shape moves — it names a thing and the host fetches it — and the container runtime stops being something
genesis must raise before the control plane can exist. It still raises one, for the store and the broker,
which is where somebody else's software belongs.
The mesh's own components — the host, the controller, the catalogue, the builder, the vault — are
delivered as binaries into directories named for their versions, by the mechanism
[ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md) describes. Third-party
software stays a container. The split is not about isolation; it is about who built the thing.
Measured before deciding it: the mesh's own components publish no ports at all, so this opens nothing.
Only the store, the registry and the broker publish, and they are staying as they are.
+101 -1
View File
@@ -7,8 +7,10 @@ code:
- mesh-controller internal/identity/authority.go
- mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-09-27
updated: 2026-09-28
decisions:
- 02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
- 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
- 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
@@ -625,6 +627,50 @@ the found firewall reloads and reachable from a container on the node, that a ma
enrols through the openings before and after a reload and a reboot, and that after the flip the
declared port is open and the undeclared one closed.
### It filters what arrives from outside, and not what the machine's own guests send
*2026-09-28, preparing the control-node's convergence —
[issue 141](../../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md), settled by
[ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which replaces
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md) and
[ADR 0139](../../02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md).*
Traffic passing through a machine is filtered, because a container's published port is traffic passing
through rather than traffic arriving at the machine itself. Having blocked it, the filter then had to
let the machine's own containers reach outward again — and it did that by listing the address ranges
they sit on. Two of those ranges were constants in this repository's code, and the rest were typed by an
operator after the flip had already cut a workstation's containers off from everything.
**The list was the mistake, not its contents.** A constant describes one machine. A typed range goes
stale and cannot tell a network the mesh made from one a predecessor left behind — on the control-node,
six ranges fall outside the constants and two of the six belong to services the mesh does not run. The
attempt to generate the list from the modules put half the rule set on the machine and made the
derivation advisory. Three records, one list.
**And the mesh has no position on a container reaching outward.** §4 exists to say which port is open
and to whom, which is about what arrives. A container of this machine's own opening a connection
somewhere is not a port opened to anybody, and the addresses it might do that from are the machine's
internal plumbing, which the mesh neither owns nor can know.
So the filter constrains what arrives from **outside** the machine and says nothing about what did not.
Traffic passing through is allowed unless it came in on one of the machine's outward links, and then
only where a declared endpoint's reach admits it (§6). The machine says which of its links face
outside — one fact it reports, like the kind of firewall it found and the tunnel it carries, not a
setting and not a list of addresses. It does not change when a module is added or removed, which is the
whole difference from what it replaces. A machine that has reported no outward link is sent no filter
at all, and keeps the one it has, because a rule written around a link with no name is a rule set that
does not load — a machine filtering nothing while its unit reports success.
Ports go on following the modules exactly as before: assign a module to a machine and the port its
assignment says it reaches on opens. Nothing about a network is said anywhere, by anybody.
*How it is checked:* a bed converges a machine carrying containers on several networks, none of them
named in any setting, and each reaches outward afterwards — which fails against the previous behaviour,
where the same flip cut them off, and is how it was written; a network made *after* the last declaration
needs no new filter; a declared port is reachable from off the private network and an undeclared one is
not; no address of a machine's own networks appears in a rendered filter, asserted on the text; and a
machine reporting no outward link is refused in the control plane with its existing filter left alone.
## 5 — Certificates
**Two authorities, kept separate on purpose.**
@@ -710,6 +756,60 @@ that verifies against the internal root and nothing else — which cannot succee
first reached the name to certify it*
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
## 6 — One statement behind exposure, filtering and certificates
*2026-09-28, preparing the control-node's convergence —
[issue 140](../../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md), settled by
[ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md).*
The three sections above each decide, independently, how far a service reaches. §3 composes a name
from a label and the node's domain. §4 opens a port to the source a listen named. §5 certifies the
names that exist, from whichever authority the proxy holds. Each is coherent on its own, and together
they mean **reachability is never stated anywhere** — it is the sum of three derivations, and a sum is
not something anyone can review or refuse.
What that costs, measured: an identity provider holding a public certificate valid 90 days and an
internal one valid 24 hours, neither asked for by any assignment, because both names existed and a
proxy certifies what it serves. And an endpoint that is not routed — git over ssh — which can be
spoken about only in the filter's vocabulary, so *this must be reachable from outside* is a setting
exactly one mechanism reads.
**An endpoint is the thing that was missing.** A module declares named endpoints: one port it serves,
what it is for, and what it would serve that to absent any instruction. A route contribution names an
endpoint rather than repeating a port number. An assignment — which is where a module's configuration
lives ([ADR 0046](../../02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md))
— then binds each endpoint to a machine port and says how far it reaches.
One value, three readers:
| reach | the filter opens | the proxy serves | the certificate comes from |
|---|---|---|---|
| `internal` | the machine port, to the private network | the internal name | the mesh's own authority |
| `public` | the machine port, to anywhere | the public name | the public authority |
| `both` | the machine port, to anywhere | both names | each name's own authority |
**An endpoint that is not routed is reached and never named.** No route contribution means no name is
composed and no certificate requested, while the filter still acts on it. That is the case the model
could not express at all, and it is the ordinary case for anything that is not HTTP.
**Nothing moves until an assignment says so.** An endpoint whose assignment is silent keeps the
default its manifest states, so every machine already converged renders exactly as it does today —
the same property §4 needed when a machine gained a way to say which networks it routes.
This is what the certificate questions were waiting for. Which authority signs a name, whether a name
may appear in a public issuance log, and what must be trusted where are all answerable once an
endpoint says whether it is internal — and unanswerable while the proxy decides by composing every
name it can. It is also the fact
[issue 139](../../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
needs: an internal name should be composed from the machine serving the endpoint, which is the
assignment that bound it.
**How it is checked** is stated with the decision: one module with two endpoints of differing reach
asserted per chain body; the names and the certificate requests following the reach and failing
against today's behaviour, where both are always composed; an unrouted endpoint filtered and never
named; an assignment naming an endpoint the module does not declare refused where it is said; and a
silent assignment rendering byte-identically to today.
## What this removes
The list is worth having in one place, because it is most of the argument:
+26 -1
View File
@@ -5,8 +5,9 @@ code:
- mesh-controller cmd/mesh-builder
- mesh-controller internal/builder
- mesh-catalog modules/builder
updated: 2026-09-25
updated: 2026-09-29
decisions:
- 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md
- 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
@@ -263,3 +264,27 @@ ships one and wrong for code the mesh built, which has no unit until the mesh wr
**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.
## The builder compiles the languages the mesh is written in
*2026-09-29 —
[ADR 0142](../../02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md).*
The toolchain list was typescript and python, and only typescript had a base module in the catalogue.
Meanwhile the control plane — written in the language this project is mostly written in — was built as
an image from a hand-written Dockerfile, which is the per-repository incantation this whole mechanism
exists to abolish.
So the list gains Go, with a base module providing the compiler exactly as typescript has one. The
obligation the list's own comment warns about — an SDK carrying the broker client, the event envelope
and tool serving — attaches to a **module** written in a language, not to the language being
compilable. The mesh's own components are not modules in that sense; the host is what applies modules.
**And an artifact says what it targets.** A compiled binary is per operating system, pinned at link
time, and a toolchain deliberately accepts nothing from the module — anything a module could override
there it would be writing a Dockerfile to override. The target is therefore a property of the artifact,
not of the recipe: one artifact declared per target, one build each.
A component's version stops being stamped in at link time. It is unpacked into a directory named for
its version, so it reads its version from its own path, and a build no longer has to know what it will
be called.
+3 -3
View File
@@ -10,7 +10,7 @@ code:
updated: 2026-09-27
decisions:
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
@@ -303,7 +303,7 @@ the private network. It is raised at genesis like the store, adopted as a module
phase.
**The deprecated broker is an ordinary module, not a compatibility layer.** Revision, second review
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)): earlier text here, and
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md): AMQP is not a provision, and everything speaks to the `mesh-broker` seat)): earlier text here, and
ADR 0106 before it, called it `lavinmq-compat` — one purpose, the predecessor's clients, and a
retirement condition of no client connected for a period the operator sets. It is none of those.
A module may legitimately need an AMQP broker as a **backing service**, the way it needs a
@@ -534,7 +534,7 @@ find what changed and why.
**Still open:**
- ~~Whether EVENTS should be one stream or one per emitting module.~~ **Closed**
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md))): one stream, and not as a
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md): AMQP is not a provision, and everything speaks to the `mesh-broker` seat) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md))): one stream, and not as a
preference — the streams the bus is made of are composed as configuration before any module
runs, because a provisioner is itself a module that needs a bus account to start. Bootstrapping
decides it.
+31 -1
View File
@@ -4,12 +4,15 @@ status: implemented
code:
- mesh-controller internal/catalogue/seats.go
- mesh-controller internal/catalogue/resolve.go
- mesh-controller internal/inventory/seats.go
- mesh-controller internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql
- mesh-controller cmd/mesh-controller/seats.go
- mesh-controller cmd/mesh-controller/source.go
- mesh-controller internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql
- mesh-catalog modules/gitea/module.json
updated: 2026-09-27
decisions:
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
@@ -42,6 +45,31 @@ is refused. A seat makes a role singular, never a module.
**The seat points at the assignment.** Everything the mesh knows about the holder is what it knows
about that assignment: the node, the node's settings for the module, and what the module serves.
**Which assignment holds a seat is a fact on record, and changes as one act.** Revision, 2026-09-27
([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)). Until
then the holder was derived — the module that is assigned and claims the seat holds it, and a second
eligible assignment was refused. That has no way to pass a seat from one holder to the next without a
moment in which nobody holds it, and the controller finds its own bus through one of these seats: that
moment took the control plane down for an evening. So the holder is now one row the controller keeps,
written by a handover — `seat <name> --to <node>/<module>` — that names the seat and the assignment
taking it over and replaces the previous holder in the same write. Between two handovers the seat has
exactly one holder, and it is never none.
Three consequences follow. **A seat with no row is held as it always was**: the sole eligible
assignment holds it, and two eligible ones are refused — so a mesh that has never handed a seat over
behaves exactly as before, and the row appears the first time somebody does. **With a row, any other
assignment whose module could hold the seat is eligible and silent**: neither refused nor holding.
That is what lets the next holder run beside the current one until the handover, which the bus's move
needs ([28](28-building-the-bus.md), task 5.3). **And a holding is the assignment's**: unassigning the
holder takes the row with it, so a seat never points at something that is not running anywhere, and
the seat falls back to derivation rather than to nothing.
The handover refuses what would make the new holder wrong before anything is written: the seat must
exist, the assignment must exist, and the module must be able to hold the seat — claim it at its scope
and provide what it delivers, judged against the store's row and not against anything compiled into a
binary. It does not check that the module is running yet; `push` confirms that afterwards, and a
handover that could only be recorded after the new holder was up could not be the switch.
**The set is closed.** A seat the mesh does not define is refused wherever it is named, and so is one
named at the wrong scope. Adding a seat is a decision, recorded, for the reason every addition to the
host's vocabulary is one: the set is what a person reads to learn what a mesh can have, and an entry
@@ -207,7 +235,9 @@ checked as their tables say:
| `mesh-*` is the mesh's, and a module may not declare one | 0118: a registration test refusing a manifest that declares any `mesh-*` seat, naming the prefix. |
| Two modules cannot declare the same seat | 0118: a registration test; the second is refused and the first untouched. |
| A holder satisfies the seat's protocol | 0118: a claim whose module does not serve what the seat declares is refused at assignment. |
| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. |
| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. 0131: `CanHold` is the one judgement, shared by registration and the handover, and its test follows the store's row. |
| A holder on record settles the seat; another eligible assignment is silent, not refused | 0131: resolution tests with a recorded holder on the same machine, on another machine, and under a seat's former name; without a record, the old rule's tests still pass unchanged. |
| A handover replaces the holder as one write, needs an assignment to point at, and goes with it | 0131: store tests — a second handover leaves one row; a handover to a module not assigned where named is refused; unassigning the holder removes the row. |
| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0118: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. |
| Several providers and none local is a person's choice | 0118: an assignment test listing candidates with the seat's holder first and recording the pin. |
| `secret` is reserved | 0118: the parser and resolution refusals for another provider and a pin. |
+61 -44
View File
@@ -5,11 +5,11 @@ code:
- mesh-catalog modules/nats
- mesh-controller internal/catalogue
- mesh-lab scenarios
updated: 2026-09-27
updated: 2026-09-28
decisions:
- 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
@@ -535,7 +535,7 @@ it, and the beds that need a mesh living on NATS can finally run.
The outcome carries the module name, because only the manifest says what was built and one
message now has three readers. A failed build names none: it produced no module version, and
the catalogue would otherwise place something that was never made.
- [~] 4.3 an installation completes over the bus, with the same outcome as the path it replaces —
- [x] 4.3 an installation completes over the bus, with the same outcome as the path it replaces —
**the installer can raise it**: a foundation template that stands up the server, writes the
server's own settings and the mesh's first user list beside them, and starts a controller
reaching the new bus. What remains is running it, which is 4.1's bed.
@@ -655,55 +655,72 @@ healthy while reacting to nothing.
- [ ] 5.1 the cutover bed: a mesh on AMQP with a predecessor stand-in on the deprecated broker
moves its bus in one rollout, every node reporting on NATS afterwards, the stand-in's own
client still connected throughout
- [~] 5.2 the rollout: accounts composed, then the controller, every host and every runtime
- [x] 5.2 the rollout: accounts composed, then the controller, every host and every runtime
together; every node confirmed heard before AMQP stops.
**The readiness half is in and is the half worth having.** The move takes every node at once, so
there is nothing to inspect afterwards and no half to roll back — either the mesh was ready or it
was not. `rollout check` answers that from records, with one dial: is a bus answering, does a
machine hold the seat, has it been sent the composed user list, does every machine and every
module that speaks have a credential. Each missing thing names its own next step, because "not
ready" that cannot be acted on is not an answer at the point where the next step is irreversible.
**A machine with no credential is what must stop it.** It keeps running, cannot come back, and
afterwards there is no bus to tell it anything over.
The move itself is deliberately not written yet, and the command says so rather than pretending:
it waits on the check having been run against a real mesh. Writing the irreversible half before
the question it depends on has ever been asked of something real is how the plan's own rule about
beds gets broken by another route.
> **What this costs if it goes wrong, measured rather than assumed.** On the installation this is
> for, the old broker is also what a whole automation layer outside the mesh connects to — so it
> stays, as an ordinary provider of `amqp` ([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)),
> and this step is not its retirement. Nothing in a served request's path goes over the mesh's own
> bus: modules serve from their own containers. What a failed move costs is the mesh's ability to
> *change* anything — pushes, tool calls, new provisioning — until it is finished or undone. That
> is worth knowing before rather than after, and it is why the operator's "as long as my services
> keep running" is a reasonable position rather than a gamble.
- [ ] 5.3 the mesh's own accounts removed from the deprecated broker, and then the broker itself:
after the rollout nothing of the mesh speaks to it, and an account nothing uses is one nobody
rotates. **It finishes now** ([ADR 0130](../../02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md)):
the predecessor is deprecated rather than kept, so once its remnants have stopped the module is
unassigned and the port is free. No retirement machinery — a provision with no consumers has its
provider unassigned, which is ADR 0127 being paid off rather than revised.
**Done 2026-09-28, 02:25.** Every machine reports on the new bus, the seat is held by the
module that provides it, the old broker is unassigned and forgotten, and every credential was
minted afresh at the end because two had been printed on the way. What it took, in the order
it was found, each fixed on the trunk before the next step: the control plane's `serve` and
`push` never selected the new transport (task 4.3, open until then); a machine's user was
granted neither the asking nor the delivery of its own consumer; the account had no JetStream
of its own; the control plane's client verified the bus's certificate by name instead of
pinning it; the seat table's rows carried no protocol, so no role's work queue was raised; the
build machine decided its bus from a variable its container never received; and a rotation
put new hashes on the bus before three machines had received their new memberships — which
is why there is now `rollout hand <node>` and a host adopts a delivered membership at start.
The bootstrap loop — a bus that can only be raised by a declaration that can only arrive
over that bus — was broken once, by hand: the mesh's own composed configuration started the
server, and the controller binary was run on the node directly until the managed container
could be rebuilt over the bus it was on.
- [x] 5.3 **the seat changes hands as one act.** A command takes a seat and the assignment taking it
over, and the seat is never empty in between — the emptiness is the outage of 2026-09-27, when
the control plane, which finds its own bus through this seat, lost the address and looped.
**Built 2026-09-27** (`seat_holding`, migration 0039; design 26 says how it is checked), and used
live the next night to hand `mesh-broker` from the old broker's assignment to the new one's. This
is what 5.2 uses to move `mesh-broker` from the old
broker's assignment to the new one's, and it is built first ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)).
- [x] 5.4 **the old broker and everything that named AMQP leave the mesh** ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md),
superseding [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)): the two modules that
required `amqp` are removed, the broker's module is unassigned and removed (**done 2026-09-28**; the predecessor's own tooling, which rode the same adopted broker, went dark with it, as [ADR 0130](../../02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md) accepted), registration refuses
a manifest that provides or requires `amqp`, and a whole-catalogue check asserts none does. Not
a retirement condition — a decision, taken, with the operator's "I don't care if the predecessor
breaks" on record ([ADR 0130](../../02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md)).
Retiring with it: the build outcome's second announcement under the module's own name, which
exists only so a catalogue deployed before the rename and one deployed after both hear it.
existed only so a catalogue deployed before the rename and one after both heard it.
> **The remote tooling goes with it too.** The predecessor's own mesh talks over that broker, so
> shutting it down ends the path that reaches this installation's machines from a workstation.
> The rollout has to be driven from the node, or driven before the broker stops — which is a
> sequencing constraint on 5.2 and not an afterthought.
> The rollout is driven from the node, or before the broker stops — a sequencing constraint on
> 5.2, not an afterthought.
- [x] 5.5 **the AMQP transport is deleted from the control plane and the hosts**. One bus, nothing
to select ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)).
> **5.4 is gone, and was wrong from ADR 0127 onward.** It read "the deprecated broker retires
> when its condition holds — no client connected for the period the operator sets", which is
> ADR 0106's framing of it as a compatibility module with an end date.
> [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) settled that it is an
> **ordinary provider** of the `amqp` provision, like any module answering a backing service:
> no seat, not foundation, and **no retirement condition**, because the day its last client
> disappears is not a day anything is waiting for. Removed rather than reworded — a step that
> waits for a condition nobody set would sit open forever.
**Done 2026-09-28.** The control plane's old consume loop, build request, tool call, management
API and account scoping went, and the host's old dialling and enrolment paths with them; a
membership or token naming any other bus is refused before anything is sent. Nothing selects a
transport any more: the variable that once did (`MESH_BUS_NATS`) now only names where the
control plane reads its own bus credential, the way any module reads a secret. **Checked by the
build**: neither repository's module file names the AMQP client library, so a line that still
used it would not compile. The store-window guarantee ([issue 083](../../04-ISSUES/083-other-control-messages-are-lost-while-the-store-restarts/00-report.md))
is tested against a bus-less fake rather than the old transport's memory, which is what let
that memory go — the one thing it did that the stream does not (superseding a held report) is
the staleness check on the message itself (design 25 §3).
Found on the way: **no build had ever recorded what it stood on.** A recipe reads its base from
a build argument, so the digest was never in the file the builder derived edges from, and every
order that says *bases first* — `build --on`, `build --behind`, the merge follow-up of
[issue 131](../../04-ISSUES/131-nothing-tells-the-mesh-a-source-moved/00-report.md) — walked a
graph with no edges. The builder now reports the bases it was handed, the control plane records
them by artifact path, and the graph is read from the newest build of each module — a recorded
manifest carries no `build.on`, so the edge is derived from the build or it does not exist.
> **The old 5.4 note is history.** It recorded that a retirement *condition* was wrong from
> [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) onward, which framed the old broker
> as an ordinary provider with no end. [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) ends that
> framing in turn: the broker is not kept as a provider either, because AMQP is not a provision. Both
> readings are kept here so the two reversals can be read in order.
**Done when.** Every node reports on NATS, and nothing of the mesh's own is left connected to the
deprecated broker.
@@ -1,17 +1,29 @@
---
layer: to-be
status: proposed
code: []
updated: 2026-09-27
status: in-progress
code:
- mesh-controller internal/catalogue/declaration.go
- mesh-controller internal/catalogue/manifest.go
- mesh-controller internal/link/serve.go
- mesh-controller internal/link/bus.go
- mesh-controller internal/broker/nats.go
- mesh-controller internal/inventory/nodes.go
- mesh-host internal/apply/apply.go
- mesh-tools src/main.ts
- mesh-catalog modules/mesh-catalog
updated: 2026-09-28
decisions:
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0041-events-are-a-relationship.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
- 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md
- 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md
- 02-DECISIONS/0136-a-step-gates-its-module-not-the-machine.md
- 02-DECISIONS/0134-the-mesh-says-what-it-applied.md
---
# 32. What a module declares, and what the bus makes of it
@@ -258,10 +270,30 @@ queue.
and publishes it last-per-subject. A node that was away gets exactly the current one, never a
queue of superseded ones, and a replayed older one is refused by sequence.
**A version prepares its state before it runs.** *Built 2026-09-28.* A module version may declare an
entrypoint that brings its state to the shape that version needs — the same vocabulary as the entrypoints it declares for its
tools and its provisioner, and nothing about how a machine runs it. The mesh runs that entrypoint as it
runs the module's own code, to completion, in the module's own context, and a version whose preparation
did not succeed does not run: the step gates that module and nothing else on the machine
([ADR 0136](../../02-DECISIONS/0136-a-step-gates-its-module-not-the-machine.md)), and the rollout stops
at the first machine that did not take it
([ADR 0135](../../02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md), superseding
[ADR 0133](../../02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)).
Once per state, and the mesh derives what a state is: a consumer is a module on a machine, so what the
mesh provisions is per consumer and preparation is too. No level to choose, and no race to lock against.
**Applying is reported to a role.** The host applies and reports to the `mesh-controller` seat —
not to an address it was given at genesis. Held and retried while the store restarts
([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)).
**And the mesh says what it applied.** *Built 2026-09-28.* A report is control traffic only the control plane reads, so the
chain above went dark at the moment it touched a machine: nothing said which version a machine now runs,
or that it refused to. The control plane states those as facts under its own seat's namespace, when what
a machine runs changes rather than on every convergence pass, and anything that cares subscribes the way
the catalogue subscribes to `built` ([ADR 0134](../../02-DECISIONS/0134-the-mesh-says-what-it-applied.md)).
The facts are second-hand by design — one emitter, one ordering — and a machine that cannot reach the bus
produces none, so absence is not health.
What disappears across that chain is every address. No webhook URL, no registered callback, no
"which node is the builder on", no controller endpoint baked into a joining node. That is the
class of bug
@@ -357,7 +389,7 @@ controller — because the bus's accounts are configuration rather than somethin
creates, so there is no provisioner process in the path and nothing waiting on a bus account in
order to make bus accounts. A module that runs a NATS server of its own and offers it as a
backing service provides **`nats`**, exactly as the deprecated broker provides `amqp`
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)).
([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md): AMQP is not a provision, and everything speaks to the `mesh-broker` seat)).
They are never the same name. A manifest saying `nats` could otherwise mean either the mesh's
nervous system or a private queue, and the difference between those is the whole architecture.
@@ -427,7 +459,7 @@ it as an ordinary module once the registry exists.
So there are exactly two things the normal path cannot make, both at genesis, both ending the
moment the mesh can mint for itself: **the bus's own accounts** (§the bootstrap argument in
[ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)) — a provisioner is a module and
[ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md): AMQP is not a provision, and everything speaks to the `mesh-broker` seat) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)) — a provisioner is a module and
needs an account before it can run) and **the vault's own credential**. Any third exception is a
design failure, and naming these two is what makes a third one visible.
@@ -439,6 +471,10 @@ it is the residue of a question the rest of §8 answers and the part a fingerpri
**Whether a module may declare a seat it does not itself claim** — the contract as one thing, the
implementation as another, which is how two competing implementations would ever exist.
**Whether a container should have a readiness notion.** Only an action carries `verify`, so a step that
must run once a service *answers* — seeding through its own API — cannot be declared at all. Named here
because the steps above make the gap obvious, not because they caused it.
**Whether `consumes` naming another module couples too tightly.** It is kept here deliberately —
an event's provenance is its meaning — but a consumer of `billing.order.placed` does depend on
billing existing under that name.
@@ -447,6 +483,11 @@ billing existing under that name.
- **A manifest holds no subject.** A catalogue test: no manifest contains a string matching the
subject grammar. The rule is worthless if it is followed by convention.
- **A preparation is given what the module is given.** A composition test: what the preparation
entrypoint receives equals what the module's own code receives, asserted rather than written twice —
which is the drift a hand-written step invites, three times over in the catalogue today.
- **A convergence that changed nothing says nothing.** Two identical reports, one emitted fact: what is
guarded against is a fact per minute per machine, which is a stream nobody reads.
- **Permissions are exactly the three namespaces.** A composition test per module: the derived
permission set equals what its declaration implies, and a hand-written addition to it fails.
- **A sender cannot read the queue it writes to.** A bed: a module declaring `uses` is refused
@@ -0,0 +1,137 @@
---
layer: to-be
status: designed
code: []
updated: 2026-09-28
decisions:
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md
- 02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md
---
# 33 — The tools the mesh answers
**An agent can call the mesh's tools and cannot find out what they are.** Both halves were measured
on the live mesh on 2026-09-28: a client holding an operator credential connected to the bus, asked a
module for its repositories and got them; the same client's request for the tool list found nothing
serving it. The transport works, the account model works, the adapter that speaks the agent protocol
works. What is missing is the mesh being able to say what it can do.
This design is the answer to that question, and it has three families in it, because a tool belongs to
whoever is accountable for answering it.
## 1. Three families, and why the split is not arbitrary
| Family | Addressed to | Where the definition lives | Example |
|---|---|---|---|
| A **role's** tools | the seat: `mesh.seat.<seat>.tool.<verb>` | the seat's protocol, in the mesh's records | ask *the forge* to list its repositories |
| A **module's** tools | the module: `mesh.mod.<module>.tool.<name>` | that module's code | ask *this gitea* for `gitea_list_repos` |
| The **mesh's** own verbs | the `mesh-controller` seat | the seat's protocol, as above | `status`, `push`, `build`, `assign` |
The split follows accountability. A role is something the mesh guarantees exactly one holder of, so
what the role answers is the mesh's to define and a holder's to implement
([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)). A module's
own tools are nobody's business but the module's, and their definitions live where they are
implemented, because a copy kept anywhere else drifts from the code that answers.
The mesh's own verbs are the third family only in where they come from, not in kind: the control plane
holds a seat like anything else, and its tools are that seat's. This is what keeps them addressable
while the control plane is being replaced, which is the moment they are most needed.
**Both names for one capability is deliberate and bounded to this.** A forge holding the `git` seat
answers the role's `list_repos` and its own `gitea_list_repos`, because the same module may run
without the seat — a second instance, kept for one purpose — and then only the second name is true.
The caller chooses which question it is asking. Nothing else in the mesh gets two names.
## 2. What a seat's tool is
A verb, what it does, and the schema of its arguments and its answer. A name alone is not callable by
something that has never seen the mesh before, which is the whole population this surface exists for.
The protocol a seat carries today is three lists of bare verbs
([ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md)), and it must widen to
carry the rest. Two constraints on that widening:
- **It lives in the mesh's records, not in the control plane's binary.** Today a seat's protocol comes
from compiled defaults, merged in as a row is read, because the seat rows never gained the columns.
Discovery that reads a binary is discovery that disagrees with the mesh the moment the two are on
different versions.
- **The schema is stated in the form an agent protocol already uses**, so nothing translates between a
seat's idea of an argument and the caller's. A translation layer would be a second definition of
what a tool is.
## 3. Holding a seat means serving its tools
A module may not occupy a seat unless it serves every verb that seat declares. This joins the
conditions of holding that already exist — providing what the seat delivers, being assigned at the
seat's scope — and is refused the same way: at registration and at handover, naming the verbs that are
missing rather than the fact that something is.
A module knows which seats it claims, so knowing which tools it must serve is not a discovery problem
for the module: the seat says, the module implements, and anything beyond that is its own.
## 4. Addressing a node-scoped seat
A seat's subject is flat today — `mesh.seat.<seat>.<kind>.<verb>` — which is correct for a seat the
mesh has one holder of and wrong for the six node-scoped seats, where one subject would reach every
machine's holder and the holders' queue group would hand the call to whichever answered first. A
node-scoped seat's tool therefore carries the node it is asked of. Nothing about a mesh-scoped seat
changes.
## 5. Discovery
**What a role answers is a read.** The seats and their protocols are records, so the list is a query
against the mesh's own store: no call to a module in the path, nothing that has to be running, and an
answer that stays true while a holder is restarting or being replaced.
**What a module answers comes from the module.** Its definitions live in its code, so it is asked, and
the answer is as available as the module is — which is the right coupling for a tool that only exists
while that module does.
A caller therefore gets one list assembled from two sources, and the difference is visible in it: a
role's tool names a seat, a module's names a module. An agent that wants to survive a holder being
replaced binds to the first.
## 6. What serves this to an agent
A module the mesh assigns to the machine where the agent runs, holding a credential the mesh minted,
with authority derived from what it may call — not a program started by hand with a credential printed
to a terminal. The adapter itself already exists and is thin by design; what changes is that it stops
being something a person carries and becomes something the mesh runs, on a node, like everything else.
An agent's authority can then be role-shaped: *the forge's tools*, rather than a list of
module-specific names that changes the day the forge is replaced.
## 7. Versioning
A seat's tools are an interface and change like one. Additive within a version. A change that would
break a caller takes the version token the subject already has room for, and the two versions run side
by side until nothing is bound to the old one.
## How it is checked
- **A holder missing a verb cannot take the seat.** One test per condition of holding, as the existing
conditions have, and the live refusal names the verbs.
- **A verb nobody declared is a subject nobody may use.** The bus grants are derived from the seat's
protocol already, and the golden composition of the user list is what keeps that honest: a holder is
granted exactly the seat's verbs, a user of the seat exactly the publish side.
- **Discovery needs no running module.** The test for a role's tools reads records and asserts the
answer equals what the seats declare — if it needed a module up, it would not be a read.
- **Two nodes holding one node-scoped seat derive two addresses.** Checked by the same test as the rest
of the subject table.
## What this does not settle
- Which verbs each seat should serve. That is a decision per seat, and the reason to do it slowly: a
seat's tools bind every future holder.
- Whether a module's own tool definitions should also be recorded when a build resolves its manifest.
There is an argument for it — the mesh could then answer for a module that is down — and an argument
against, which is that a recorded copy of a live definition is a copy that can be wrong.
## References
- [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md) — the decision this designs
- [ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md) — a seat carries the protocol of its role
- [ADR 0095](../../02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md) — a tool call passes one process where an audit belongs
- [`26-the-seats.md`](26-the-seats.md) — what a seat is, how it is held and handed over
- [`25-the-bus-on-nats.md`](25-the-bus-on-nats.md) §7 — a person's account, their inbox, and the adapter
+1 -1
View File
@@ -40,7 +40,7 @@ document is written and this one's status becomes `implemented`.
| [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) |
| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **Proposed.** The mesh models machines but not the humans on them: a node gains operator accounts, and a resource may live under a home owned by its account — what would own ~/.ssh, dotfiles and ~/.config when HAL retires | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) |
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
## Not yet written
@@ -0,0 +1,107 @@
---
status: resolved
opened: 2026-09-27
located-in: [mesh-catalog modules/gitea, mesh-controller cmd/mesh-controller, mesh-controller internal/builder]
fixed-by: mesh-catalog #124 — the forge module watches for merged pull requests and emits `pull.merged` with the merge commit and the clone address; mesh-controller #110/#111 — the control plane follows that event on the bus, marks every module built from that repository and branch as moved, and builds them bases first, stopping when a base fails; mesh-controller #113/#114 — a build records the bases it was handed and the graph is read from builds, without which "bases first" had no edges to order by.
amended-design: 03-DESIGN/01-to-be/28-building-the-bus.md
---
# 131 — Nothing tells the mesh a source moved, and it reports itself current anyway
## What was observed
Six changes were merged to the trunk of six repositories in one sitting. The build machine built
nothing. Its last build, minutes before the first merge, was still the one it reported; no build was
requested, refused or failed, because none was ever asked for.
Asked afterwards what was wrong, the mesh said:
> 4 machine(s), all doing what they were told, all heard from, running what the mesh would send them,
> and every module current with its source
Every one of the six had moved. The last clause was false for all of them, and it is the clause a
person reads to decide whether there is anything to do.
## Why it matters beyond this instance
**The mesh learns a source moved by being told, and there is no longer anything to tell it.** The
command exists — a person names the module and the commit — and so does the question the overview
answers. What is missing is whatever used to connect the two. One repository still carries a forge
webhook aimed at a port; the rest carry none, and the port belongs to a different service than the
one the arrangement implies. So the state is not "the trigger is broken" but "there is no trigger,
and nothing says so".
**A wrong answer is worse here than no answer.** "Every module current with its source" is
indistinguishable, to a reader, from a mesh that has genuinely caught up. The overview is built to be
the thing you check instead of checking by hand, so a confident false negative removes the habit that
would otherwise have caught it. Nothing in the mesh is at fault for being out of date — it is at
fault for saying it is not.
**It is also why "current with its source" cannot be a stored fact.** The mesh compares what it built
against what it was last told the source was, and calls that agreement. Two facts agreeing tells you
nothing when both come from the same place.
## The intended shape, which is decided and not built
The forge emits what happened to it — a pull request merged — and the build machine reacts by
building what that commit affects. That keeps the forge ignorant of the build system and the build
machine ignorant of the forge's internals, which is the same argument
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) makes for addressing an event
to its emitter: the merge is a fact about the forge, and what should be rebuilt because of it is not
the forge's business to know.
The forge's module already declares the event. The build machine declares that it consumes nothing.
## What the trigger cannot be
**Not one build per changed module.** The modules form a graph: several are built from one
repository, and some are the base another is compiled on — a runtime image, a compiler base, a
repository whose context a second module builds from. Firing a build for each changed module
independently would start work that cannot succeed yet and produce a failure per dependent, for one
cause.
Observed while catching the mesh up by hand on 2026-09-27: a compiler base had to move before
anything compiled against it could build, and when it failed, the right behaviour was for its
dependents to wait rather than each fail the same way. Fifteen modules shared the cause. A trigger
that reports it fifteen times has buried it.
So whatever reacts to the forge's event resolves what changed into an order, builds the bases first,
and holds a dependent while its base is unbuilt or failed. That is a larger thing than "rebuild what
the commit touched", and knowing it now is cheaper than discovering it from fifteen identical
failures.
## Open questions
- Is "the source moved" still a thing a person can assert by hand once the event path exists, or does
the hand-operated form become the thing that made this failure possible?
- Which commit does the build machine act on — the merge, or each commit it brought — and what does
it do when several arrive for one module at once?
- How does the overview stop being able to lie? Comparing what was built against what was recorded
will always agree. Whether the trunk has moved is a question only the forge can answer, so either
the overview asks it, or it stops claiming to know.
- Does this want to be the same mechanism as the build request on the bus
([ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md)), or does it sit in
front of it?
## What was done (2026-09-28)
The shape above was built as described: the forge's module emits the merge, the control plane
consumes it, and nothing on either side knows the other's internals. The build is asked for the
merge commit, not each commit the merge brought — the trunk moved once, to one place. Several merges
for one module arriving in a row are followed in turn, each moving the recorded source to its own
commit, so the last one to arrive is the one the mesh ends up built from.
The hand-operated form stays. `module moved` is how a source is recorded without a forge — a module
built from a repository elsewhere, or a mesh whose forge module is down — and it is the same act the
event performs, so the two cannot disagree about what "moved" means.
**Bases first needed edges, and there were none.** The order this report asked for was written and
walked a graph that no build had ever recorded: a recipe reads its base from a build argument, so the
digest was never in the file the builder read edges from. A build now reports what it was handed, the
control plane records it by artifact path, and the order is read from each module's newest build.
**What still can lie.** The overview compares what was built against where it was last told the
source is; the forge's event is now what moves that mark, so it is right for as long as the forge
module was listening. A merge made while that module was down is a merge the mesh does not know of
until the module next polls — it announces what merged since it last looked, so the gap closes when
it comes back, and not before. The overview does not say so.
@@ -0,0 +1,60 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-controller cmd/mesh-controller]
fixed-by: mesh-controller — `module add` takes `--path` and `--self`, so a module handed over by hand records the whole location it came from; a record naming a repository and no directory says so in the reply; the rule is one function with a test beside it. The nine records already wrong were corrected by rebuilding each with its real directory, which is the same act through the same door.
amended-design:
---
# 132 — A module can be recorded without the directory it lives in
## What was observed
Nine modules on one mesh could not be rebuilt. Each attempt failed the same way:
> has no module.json at its root, so there is nothing saying what it is
All nine were recorded as coming from a repository that holds many modules, each in its own
directory — and each record named the repository and no directory. So every build cloned the
repository and looked for a manifest where there has never been one.
The failure only surfaced when something asked for all of them at once. Before that, the overview
said every module was current with its source, because what it compares is what was built against
what the mesh was last told the source has, and neither half knows whether the source can be found
at all.
## Why it matters beyond this instance
**A module is a repository and a directory inside it** ([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)),
and one of the two doors into the catalogue could record only the first half. A build records the
directory it was given, so a module that arrived by being built is always whole; a module handed over
by hand had no way to say where it lived, and the flag to say it did not exist. The rule was decided
and enforced on one path out of two.
**Half a location reads exactly like a whole one.** Nothing in the record is empty in a way a person
would notice: the repository is there, the branch is there, the commit is there. The mesh only finds
out at the moment it needs the manifest, which is the moment it is trying to rebuild — and the module
stays on whatever it last built, indefinitely, with nothing saying why.
**It is the same shape as [131](../131-nothing-tells-the-mesh-a-source-moved/00-report.md).** A
comparison between two facts the mesh holds about itself will agree with itself. Whether the source
can be found is a question only an attempt to read it answers, and the answer had nowhere to go.
## What was done
`module add` takes the directory and which forge holds the repository, so a hand-registered module
records the same whole location a built one does. What a record must say to be worth anything is one
function with a test beside it, rather than a paragraph in a help string: provenance together or not
at all, a directory needs a repository to be inside, a path on the mesh's own forge is not an address.
And a record that names a repository but no directory says so when it is made — not refused, because a
module really at a repository's root is ordinary, but said, because the person adding it is the one
who knows which it is.
The nine wrong records were corrected by building each with its real directory, which re-records it.
No row was written by hand.
## What is still true
A directory that does not exist in the repository cannot be refused when the module is added: the
control plane does not clone, and inventing a check there would mean it did. The first build says so
plainly, which is one build rather than nine, and the record it leaves behind is right from then on.
@@ -0,0 +1,78 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-controller module.json]
fixed-by: mesh-controller — the control plane's module declares a run-once `migrate` step before its server, which is the shape ADR 0052 prescribes for exactly this. A step's record of having run is the digest of its declaration and the image is part of that digest, so a new build of the control plane re-runs it; and because a run-once step gates what the declaration places after it, a migration that fails stops the new server from starting at all rather than letting it run against a schema it does not have.
amended-design: 03-DESIGN/01-to-be/32-what-a-module-declares.md
---
# 133 — The control plane's schema is migrated at birth and never again
## What was observed
On 2026-09-28 at 08:17 the control plane was replaced, by the mesh's own upgrade path, with a build
whose code writes a column that a migration **in that same build** creates. Nothing ran the migration.
For the next three quarters of an hour the mesh built things and recorded none of them. Every build
answered:
> ERROR: column "built_contexts" of relation "build" does not exist (SQLSTATE 42703)
and that sentence went only to whoever happened to be waiting on a build's reply. The overview kept
saying the mesh was fine. The builds themselves worked — images were built and published — so the
registry filled up with artifacts the mesh has no record of, and the graph stopped learning without
anything saying so.
The schema was created once, at genesis, by an action in the foundation bundle that runs the same
binary's `migrate`. Nothing runs it again. The mesh has updated its own control plane many times since
that bundle, and every one of those updates carried whatever migrations the new build brought and
applied none of them. This is the first time a build needed one.
## Why it matters beyond this instance
**The schema and the code that needs it ship as one artifact and are applied by two mechanisms, only
one of which is automatic.** A module's version is atomic everywhere else in the mesh — the manifest,
the image and what the machine runs move together. Its schema did not, so "the mesh updates itself on
a push" was true of the code and false of what the code needs.
**The failure is quiet exactly where quiet is worst.** A build that cannot be recorded is a build that
happened and left no trace, which is the fault [issue 050](../050-the-catalogue-knows-nothing-built-before-it/00-report.md)
and [issue 131](../131-nothing-tells-the-mesh-a-source-moved/00-report.md) are both about. The mesh
has three mechanisms for noticing a module is behind its source and none for noticing that what it
recorded was refused.
**The shape was already decided, and the control plane was the one module that did not use it.**
[ADR 0052](../../02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md) says a run-once container is a
step the host runs to completion before whatever the declaration places after it, and names migrating
a schema as the case it exists for. The genesis code's own comment says a manifest may name its image
in more than one resource — "a migrate step beside the server". The control plane's manifest had no
such step; it went straight from a state directory to the server.
## What is still true
**Additive migrations are load-bearing, not a style preference.** The step runs before the *new*
server starts, which means the old binary briefly runs against the new schema. A migration that
removes or renames something would break the running control plane in the window between the two.
**A hand-written step is one the next module forgets**, which is why this fix is not where the matter
ends: [ADR 0135](../../02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md) makes it
derived and puts it where an author works: a module version declares an entrypoint that prepares its
state, and the mesh composes the gated work from it, so the control plane stops being the only module
that had to remember. That record also settles the level question HAL answered with stages — a consumer
is a module on a machine, so the scope of preparation is the scope of the state — and
[ADR 0134](../../02-DECISIONS/0134-the-mesh-says-what-it-applied.md) answers the second open question
below: what a machine applied, and what it refused, become facts on the bus rather than a line in a log.
**The mesh now has two shapes for one problem.** The catalogue module migrates its own schema in its
own code when it starts; the control plane migrates in a step the host gates on. Both work and the
reasons differ — a module that owns its store entirely can do it at start, while a step is visible in
the declaration and refuses to let a broken upgrade serve. Which one the mesh should standardise on is
a decision, not a fix, and it is not made here.
## Open questions
- Should a module be refusable at registration when it ships migrations and declares no step and no
other way to apply them? The mesh can see both halves.
- Should a record the store refuses reach the overview? Today the only reader of that failure is
whoever asked for the thing that failed, and for an event arriving on the bus there is no such
person.
@@ -0,0 +1,66 @@
---
status: open
opened: 2026-09-28
located-in: [mesh-catalog, mesh-controller internal/catalogue]
fixed-by:
amended-design:
---
# 134 — A definition may still name the mesh, and the check that would say so does not exist
## What was observed
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) says a module
definition names no node, no mesh and no host path, and states how that is checked:
> A catalogue test finds no domain name in any definition value.
There is no such test. Run by hand on 2026-09-28, across the 72 manifests in the catalogue, the
question it asks has 15 answers. They are not all the same kind of thing, and the difference matters
more than the count:
**Values the mesh acts on** — seven:
| module | where | what it names |
|---|---|---|
| keycloak | `env.KC_HOSTNAME` | this installation's public name for itself |
| minio | `env.MINIO_BROWSER_REDIRECT_URL` | the same, for its console |
| invoicing | a resource's `image` | a named registry rather than the mesh's artifact store |
| builder | `build.artifacts[].context.repository` | the forge, by URL |
| route-proxy | `build.artifacts[].context.repository` | the forge, by URL |
| route-adapter | a resource's `content` | a proxy's dynamic configuration |
| novox.be | `module` | the module is named after the domain it serves |
**Prose** — eight, in `listens[].why`: de-spiegel, mailu, n8n, only-office, photos, photos-eef,
photos-filip, portainer. Each explains what a port is for and mentions the public name it is reached
by. Nothing reads these; a check written as a string search would report them, and reporting them as
violations of the same rule would be wrong.
## Why it matters beyond this instance
**An unenforced rule is indistinguishable from a wrong one, and costs more, because people believe
it.** The record says the mesh is name-agnostic, four design documents rest on that, and a reader
checking whether it holds finds that it does not — in the places that matter most. The two forge URLs
are what a build reaches into for its source; the two hostnames are what a service tells a browser
about itself.
**It is the difference between a mesh and this mesh.** A definition carrying `novox.be` is a
definition that can only be installed here. The whole point of the rule is that the same catalogue
raises a different mesh with a different name, and today seven modules would need editing to do it.
**And the shape of the fix is not the same for each.** A public name is an operator's choice about an
assignment, which ADR 0112 already provides for; a forge URL should be a path on the git seat
([ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md)); an image from a named
registry is a question about the artifact store, not about naming. Counting them together would hide
that.
## Open questions
- Does a domain in a `why` string break the rule? It is documentation the mesh never reads, and a
check that cannot tell the two apart will either pass things it should catch or fail things nobody
should change.
- Where does a service's public name live, concretely — a setting on the assignment, or a fact the
mesh composes from the node's domain? ADR 0112 says a requirement the mesh resolves; the two
hostnames above are the first real cases.
- Should a build context name a repository on the git seat rather than by URL, and if so, what does
that mean for a context in *another* mesh's forge?
@@ -0,0 +1,70 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-host internal/apply]
fixed-by: mesh-host — a container's mesh names are part of the spec digest the host compares, sorted so the digest does not move for a reordering. A container whose names moved is now recreated like a container whose image moved, and the test fails against the previous behaviour.
amended-design:
---
# 135 — A container's mesh names are not compared, so a moved address is never noticed
## What was observed
One container on this mesh had been restarting every thirty seconds for five days — 2286 times — and
the mesh reported the machine as doing what it was told.
Its logs said its database connected and then a query timed out. The database was reachable: the same
query from the same network, with the same credential, answered in milliseconds. What differed was the
name. Inside that container, `novox.internal` resolved to `10.42.0.1`; in every other container on the
machine it resolved to `10.10.0.1`. The mesh's overlay range had moved, and this container still held
the old one:
```
umami created 2026-09-23 novox.internal:10.42.0.1
mesh-catalog created today novox.internal:10.10.0.1
```
A container resolves other machines and public names through the entries the mesh gives it when it is
created, and nothing re-reads them afterwards. The host compares a container against what was declared
by a digest of its spec — image, name, environment, ports, volumes, arguments, resolver, address, and
what it reads — and **the mesh's names were not in it**. So this container matched what was declared,
was left alone, and kept an address that had not existed for five days.
Forty-eight other containers had current names. Not because anything corrected them: each had been
recreated for some other reason — a new image, a changed file — and picked up the current roster on the
way. This one's image is an upstream release that had not moved, and nothing else about it changed, so
nothing ever recreated it.
## Why it matters beyond this instance
**It is the exact fault [issue 045](../045-a-container-keeps-the-values-it-started-with/00-report.md)
named, in the one field that was left out.** That issue is why the digest carries what a container
reads: "a container whose configuration had since been rewritten compared equal and was left alone —
running values the machine no longer holds, while every check reported success." The same sentence
describes this, with *names* in place of *files*.
**The failure is invisible in exactly the way that matters.** The container runs, so the machine
reports it applied. It restarts, but a restarting container is a normal sight during an upgrade. The
only account of the fault is inside the container's own log, in the words of the application rather
than of the mesh — and what it says is that a query timed out, which points at the database.
**And it is most likely to bite what changes least.** Every container that is rebuilt often repairs
itself by accident. The victim is the module whose image is stable — which is to say, the module that
was working fine.
## What was done
The mesh's names are part of the digest, sorted so the digest does not move for a reordering nobody
made. A container whose names moved is now recreated exactly as one whose image moved.
The first apply after this recreates every container that carries mesh names — one restart each,
already the price the mesh pays for any image update — because their recorded digests predate the
field.
## What is still true
The mesh gives a container its names at creation and has no way to change them in place. That is the
container runtime's shape, not a choice; the answer is to recreate, which is what this does. A module
that would rather re-read a roster from a file can already ask for one as a fact
([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)) and restart on
it.
@@ -0,0 +1,92 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-catalog modules/fail2ban]
fixed-by: mesh-catalog — the intrusion-prevention module bans through an action it ships itself, already in use on every machine, instead of naming a firewall front-end two of them do not have. The instance is closed; the class in "What is still true" is not.
amended-design:
---
# 136 — A module may name a program the machine does not have, and everything reports success
## What was observed
Two machines were given the intrusion-prevention module on 2026-09-28. Both refused to start it:
```
ERROR Failed during configuration: Have not found any log file for 'recidive' jail.
ERROR Async configuration of server failed
fail2ban.service: Main process exited, code=exited, status=255/EXCEPTION
```
The jail that bans whoever keeps coming back reads the service's *own* log, and the service checks
every jail's log file while it configures itself — before it has created that log. The module
declared the jail and shipped the rotation for that log, and never declared the log. On the two
machines where it had run for years the file was simply there, so nothing had ever noticed.
That failure was loud. Fixing it uncovered a second one in the same module that is not.
The module's defaults named `ufw` as the way to ban an address. Two of these four machines have no
`ufw` — they filter with nftables — and nothing checks that until an address is banned. Asked to ban
a documentation address on such a machine, the service accepted the instruction, counted it, ran the
command, and wrote this to a log nobody reads:
```
ERROR ... -- stderr: '/bin/sh: line 5: ufw: command not found'
ERROR ... -- returned 127
ERROR Failed to execute ban jail 'sshd' action 'ufw' ... Error banning 192.0.2.99
```
No rule existed afterwards. Throughout, the unit was `active`, the module was applied, and the
machine's report said so. **A machine had been added to the mesh's intrusion prevention, reported as
protected, and was banning nobody.**
## Why it matters beyond this instance
**The two faults are the same mistake with opposite symptoms.** Both are the module assuming
something about the machine — a file that happens to exist, a program that happens to be installed.
One stopped the service, which anybody notices. The other left it running and empty, which nobody
does. A mesh that only catches the loud one is a mesh whose coverage is unknown.
**"The unit is running" was taken for "the module is doing its job".** That is the only health a
service resource has. It is the right answer for most modules and it is silent for any module whose
work happens later, on an event — a ban, a renewal, a backup, a notification. The report cannot
distinguish "protecting this machine" from "installed and inert".
**And it is exactly the naming rule, one level down.**
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) says a
definition names no node, no mesh and no host path, because the same definition has to raise a
different mesh. `ufw` is not a node name, but it is the same class of assumption: a value the module
cannot know, true on some machines and false on others, written as though it were a constant. The
module already knew how to do better a few lines away — the mesh's own address range is named there
as something the machine fills in.
## What was done
The module declares the log its own jail reads, created once and never touched again, since what
grows in it is the service's and the rotation the module already ships is what keeps it small. And
it bans through the action it ships itself, which every machine here can run, which was already in
use by the other jail on all four, and which covers a container's published port as well as the
host's own.
All four machines now run it, with both jails, and a ban lands on each — verified by banning and
unbanning a documentation address on every one.
## What is still true
**Nothing would have caught either fault before it shipped.** The control plane reads a manifest, not
a machine; `ufw` and `/var/log/…` are strings in a file it has no way to evaluate. The host could in
principle be asked whether a declared program exists, but no resource says "this file names a command
that must be there", so there is nothing to check.
**Two machines' bans from before this are stale rules in the old front-end**, which the service no
longer knows about and will never lift. They reject two addresses for ever. Harmless, and a reminder
that changing how a module enforces something leaves what it already enforced behind.
## Open questions
- What does a service resource's health mean for a module whose work is event-driven? A unit being
active is the weakest claim available, and four of this mesh's modules are of that kind.
- Should a declaration be able to say that a resource depends on a program, so the machine can refuse
what it cannot carry out rather than reporting success?
- Where should the packet filter a module bans through come from — the module's own choice, as now,
or the seat that owns the machine's filtering?
@@ -0,0 +1,74 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue]
fixed-by: mesh-controller — a machine says which networks it routes and the derived filter forwards them, their guests keeping address and name service ([ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md)).
amended-design:
---
# 137 — Converging a machine cut off its own guests, and nothing said so
## What was observed
A workstation was flipped from adopted to converged, so the mesh's derived filter replaced what was
there. The flip reported success, the machine reported that it had applied its declaration, and every
surface of the mesh read green.
A container on one of that machine's networks could no longer reach anything:
```
192.168.64.2/20
OUTBOUND BLOCKED
```
Five of the machine's container networks were affected, and every network its test beds create. The
reason is in the filter's forward chain, which denies by default and then allows two ranges:
```
ip saddr 172.16.0.0/12 accept # the container runtime's bridge networks
ip saddr 192.168.128.0/17 accept # the networks its compose files are given
```
Those two are constants in the controller. The machine's guests were allocated from neither: its
compose networks from other parts of `192.168/16`, and each test bed a fresh `10.x/24`. So the rules
were correct for a machine whose runtime was left at its defaults, and a guess on this one.
Two further things were closed by the same flip, and for the same reason nobody saw them: a guest asks
its host for an address over DHCP and for names over DNS, both of which arrive at the input chain,
where no module had declared them.
## Why it matters beyond this instance
**The preview could not have warned.** It lists what the machine reported as *listening*, and says so
honestly: it ends with a line that traffic the machine routes is "not previewed". What it did not say
is that routing was about to be denied by default, or which ranges would survive. An operator reading
a 350-line preview approves what it shows.
**It is the second time today that a constant stood in for something the mesh cannot know.** The
intrusion-prevention module named a firewall front-end two machines do not have
([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)), and the
filter names the address ranges one runtime happens to use. Both were true where they were written and
silently false elsewhere.
**And the code already knew.** The comment above those two lines says a machine configured otherwise
"needs this to say so — which is a thing the mesh cannot derive and a reason this list is named here
rather than computed". The gap was documented at the point where it was introduced, and the way to say
it was never built. A comment naming a missing mechanism is a rule that is not enforced.
## What was done
A machine says which networks it routes; the filter forwards them and admits their guests' address and
name service. Added to the runtime's defaults rather than replacing them, so a machine that names one
range keeps the others. Node-level, because the machine routes them and the module that loads the
filter may be replaced. The converge preview now says what a machine routes, and what it will keep
forwarding if it says nothing.
## What is still true
**The flip is still the moment a machine's unmanaged services close.** That is what converging means
and the preview names each one. This issue is not about the ports that were meant to close; it is about
the ones nothing could name.
**Egress is still not previewed per network.** The preview says which ranges will be forwarded, not
which of the machine's guests sit inside them. Deriving that would need the machine to report its
bridges, and a bed's bridge does not exist until the bed runs.
@@ -0,0 +1,56 @@
---
status: open
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue, mesh-catalog]
fixed-by:
amended-design:
---
# 138 — Two modules claim one seat and are not interchangeable, and nothing says so
## What was observed
Three modules claim the node-scoped uplink seat: one for each network manager a machine here might
run. [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) gives each of them the same
job — ask the manager the machine already runs to leave the resolver file alone and to leave the mesh's
interface alone — and deliberately keeps the machine's own links out of the mesh's hands.
A seat means one holder and an interchangeable holder. These are interchangeable in what they *ask*
and not in what they *do*:
- None installs, enables, starts or stops the manager. That is on purpose: stopping it takes every
link down, including the mesh's own way in.
- None carries an address, a route or a wireless credential, for the same reason.
- **Nothing checks that the module holding the seat names the manager the machine is actually
running.** Assigning the systemd-networkd holder to a machine running NetworkManager writes a file
for a daemon that is inactive and disabled, the seat reports held, and the two things the seat
exists to arrange are arranged for nobody. NetworkManager goes back to rewriting the resolver file
on every lease, which is the failure the module's own comment describes.
The machine reports which service manager and which units are active, so the fact needed to catch this
is already in the report the mesh holds.
## Why it matters beyond this instance
**A seat is the mesh's promise that a role is filled.** If the holder can be a module for software
that is not running, the seat says a role is filled while nothing fills it — and the surface that
would tell an operator says "held".
**It is the same shape as two faults found the same day.** A module named a firewall front-end the
machine does not have ([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)),
and the filter named address ranges one runtime happens to use
([issue 137](../137-converging-a-machine-cut-off-its-own-guests/00-report.md)). Each is a claim about
the machine that nothing on the machine checks.
**And it decides whether the seat is worth having.** Either the holder must match what the machine
runs, which is a condition the mesh can check from the report it already has, or the holders must be
able to switch the manager, which ADR 0117 refuses for a reason that has not changed.
## Open questions
- Should a seat's conditions of holding include a capability the machine reports, so a holder naming
absent or inactive software is refused rather than recorded?
- Is "the uplink" one seat at all, if its holders are three dialects of the same two requests? The
alternative is one module that speaks whichever dialect the machine needs, chosen from the report.
- What should happen on a machine that switches manager afterwards? The seat would then be held by the
wrong module, and the machine is the only place that knows.
@@ -0,0 +1,51 @@
---
status: open
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue]
fixed-by:
amended-design:
---
# 139 — An internal route name resolves to the consumer's node, not the one that serves it
## What was observed
A module that requires a route is given two names: a public one composed under the serving node's
domain, and an internal one composed under the consumer's own machine — `<label>.<node>.internal`.
The two are published differently:
- The **public** name is written into every machine's hosts file at the address of the node whose
proxy answers it. The mesh computes that deliberately, so any container resolving a routed name
reaches the proxy.
- The **internal** name is resolved by the machine's own resolver, which answers every name under
`<node>.internal` with that node's address — the consumer's, because the name was composed from it.
Where the proxy runs beside the consumer these are the same machine, which is every case on this mesh
today, and both names work. Measured on 2026-09-28: the internal name of a service on the control node
answers with a certificate from the mesh's internal authority, and the public name with one from the
public authority.
Where the proxy is on another machine they disagree. The internal name sends the client to a machine
that runs no proxy and has nothing listening on the port, while the public name sends it to the one
that does.
## Why it matters beyond this instance
**It is latent exactly where the mesh is heading.** `route` is provided mesh-wide precisely so a
module can be routed by a proxy on another machine. The first module assigned that way gets an
internal name that does not work, and the public one that does — with no error anywhere, because both
names resolve.
**A per-machine name is what an operator will reach for.** `<service>.<machine>.internal` reads like a
promise that the service on that machine is reachable there, and the wildcard makes every such name
resolve whether or not anything answers.
## Open questions
- Should the internal name be composed under the serving node, like the public one, or should it stay
the consumer's and be published at the serving node's address like the public name is?
- Is a per-node route holder the real answer — a proxy on every machine that serves its own names —
and if so, is `route` still one mesh-wide provision or a node-scoped seat with a mesh-wide fallback?
- What certifies the name in either case? The certificate is obtained by whoever terminates TLS, and
that is the question above in another form.
@@ -0,0 +1,79 @@
---
status: located
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/manifest.go
- mesh-controller internal/catalogue/filtering.go
- mesh-controller internal/catalogue/declaration.go
- mesh-controller examples/route-proxy
- mesh-catalog (every routed module manifest)
fixed-by:
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
# 140 — An endpoint's reach is not declared, so three mechanisms each decide it separately
## What was observed
Preparing to converge the mesh's control-node — the last machine still running the firewall it
had before the mesh — the question came up for one module: the forge serves git over ssh, and that
port must stay reachable from outside the private network. Where is that said?
The manifest declares the port with a source of `mesh`, so the derived filter would close it to
everything but the private network. Looking for the place an assignment says otherwise, there are
two per-node settings keys: one that gives a module's declared port a machine port, and one that
overrides a declared port's source. The second has exactly one caller — the function that builds
the node's filter rules. Nothing else in the control plane reads it.
A module's routed endpoint is declared somewhere else entirely: a route contribution naming a label
and a port. It says nothing about reach. The proxy composes a **public** name and an **internal**
name for every route it is given, and obtains a certificate for each from a different authority.
Measured on that machine the same day: an identity provider's public name signed by the public
authority for 90 days, its internal name signed by the mesh's own intermediate for 24 hours and
renewed daily. Both names exist, and both certificates, because the proxy makes every name it can.
No assignment asked for either.
So the forge's ssh endpoint has a firewall source and nothing else — no name, no certificate, and no
way to say it should be public other than a key the filter alone reads. And the forge's web endpoint
has two names and two certificates that nobody requested.
## Why it matters beyond this instance
**Reach is stated twice, in two vocabularies, in two places that cannot disagree out loud.** A port
may be exposed to anywhere while the module contributes no public route; a public route may be served
for a module whose own listen is private. Nothing reconciles the pair or refuses it. Each mechanism
is separately defensible and the combination is unstated.
**The vocabulary belongs to the filter, not to reachability.** *Public, internal, or both* cannot be
expressed. A source of `anywhere` is one rule on one chain; it says nothing about which names should
exist or which authority should sign them. So "this endpoint must not be public" has no way to be
written, and is therefore enforced by nothing — while a public certificate for that very name is
obtained automatically.
**An endpoint is not a thing in the model.** A module has ports, and separately it has routes.
Nothing binds a port to a name to a certificate, which is why three mechanisms each decide reach on
their own and none of them is wrong. This is
[ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)'s fault
one level up: that record closed "a declaration that reads as a restriction and restricts nothing"
for the packet filter. Here the declaration is absent altogether and the mechanisms guess.
**It blocks the certificate work.** The open question recorded for certificates — a name that must
not be public needs either DNS-01 or the internal authority only — cannot be answered while no
assignment states whether a name should be public. Neither can expiry reporting, revocation, or what
happens to a name when a machine leaves: all of them need to know which names were *meant*.
## Open questions
- Should an assignment name each of a module's endpoints, bind it to a node-level port, and state
whether it is reachable publicly, internally or both — with the filter, the proxy's names and the
certificate authority all derived from that one statement?
- What is an endpoint that is neither routed nor certified? Git over ssh is public reach with no name
and no certificate; the model has to hold that without inventing one.
- Are the two existing settings keys the same statement, half-built? If so, is this a new declaration
or the completion of theirs?
- Does an internal-only endpoint get a certificate at all, and from which authority — and does that
settle [issue 129](../129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md), where nothing
installs the mesh's own root?
- Does declaring reach per assignment also settle
[issue 139](../139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md), where an
internal name resolves to the consumer's machine instead of the one serving the endpoint?
@@ -0,0 +1,88 @@
---
status: located
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/filtering.go
- mesh-host internal/apply
fixed-by:
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
# 141 — The forward chain does not follow the modules, though the modules declare their networks
## What was observed
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md), decided the same
week, gave a machine a way to say which networks it routes for its guests, because the derived
filter's forward chain had until then allowed two ranges named as constants in the control plane's
own source — the container runtime's default bridge pool, and half of the pool its compose files
are given.
Checking the last machine still to be converged, the same fault was found to be live there, and the
declaration needed to work around it turned out to be wrong in kind.
That machine hosts twenty-one container networks. Nine fall inside the runtime's bridge pool and
are forwarded. Twelve sit in the other private range, and **six of those fall below the lower bound
of the constant**, so the flip would have cut their guests off exactly as it did on the workstation
that produced 0137.
Naming a range to cover the six was the obvious move, and is what 0137 provides for. But of those
six networks, **four are networks the mesh's own modules declare** — they appear as network
resources in the node's plan, created by the host because a module asked for them — and **two are
leftovers of the predecessor**, compose networks of services the mesh does not run. A range wide
enough to keep the four would have forwarded the two as well: a firewall widened by hand to protect
networks that should not exist.
The mesh already knows which of the twenty-one are its own. It made them.
## Why it matters beyond this instance
**The node's configuration is supposed to follow the modules assigned to it.** That is the mesh's
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
from what runs there. The forward chain is the one derived thing that does not: it consults two
constants and, since 0137, a list a person types. A module added tomorrow brings a network the filter
will not forward; a module deprecated leaves a range in the list that outlives it.
**A typed range cannot distinguish the mesh's networks from what was left behind.** It is stated in
addresses, and addresses are what the runtime allocates, so the only honest declaration is one wide
enough to include whatever else the runtime has handed out. The derivation is narrower than anything
a person can safely write, because it names networks rather than ranges.
**0137 rejected deriving this, and was right about what it rejected.** It considered deriving the
list from *what the machine reports* and refused, on two grounds: a test bed creates its bridge
between one declaration and the next, and a filter that follows whatever appeared on the machine is a
firewall that widens itself. Deriving from the **declaration** is neither. The set is known before
the network exists, because a module declared it; and it cannot widen itself, because only a network
some module asked for is ever forwarded. What remains genuinely for a machine to say is guests no
module declares — a test bed's pool — which is a much smaller residue than the list as it stands.
**The gap is invisible in the one place that should show it.** The converge preview lists what
*listens*, and routing is not a listener. It says in one line what the machine routes, and a reader
has to know the runtime's allocations to tell whether that line is sufficient. On the machine
measured here it read as though nothing needed saying.
## What was decided
*2026-09-28, later the same day.* The answer is not a better list. The question in the first open
item below — should the chain be derived from the networks the modules declare — was answered *no*,
after a converged machine's rendered rules were read: the chain blocks everything passing through the
machine and then allows its own guests back by listing their addresses. Every route to a correct list
fails, because the mesh has no position on a container reaching outward in the first place. The filter
now constrains what arrives from **outside** the machine and says nothing about what did not, and a
machine says which of its links face outside — one reported fact instead of a list. See
[ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which
supersedes both 0137 and the first attempt at answering this.
## Open questions
- Should the forward chain be derived from the network resources the node's modules declare, with the
host resolving each declared network to its address the way it already resolves a container by name?
The controller cannot render the address itself: a module's network resource carries a name, and the
runtime allocates the subnet at creation.
- What remains of `node networks` once that exists — only guests no module declares, such as a test
bed's pool? And should it then be named for that, rather than for all routing?
- The runtime's own default bridge, which containers attach to when no module network is named, is
not a module's network. Is it derived from the machine, declared by the module that owns the
runtime, or left as the one constant?
- Should the preview say which of a machine's networks are the mesh's and which are not, so a range
that exists to protect a leftover is visible as such?
@@ -0,0 +1,76 @@
---
status: located
opened: 2026-09-29
located-in:
- mesh-host internal/upgrade
- mesh-host cmd/mesh-host
- mesh-controller (no build source for the host; no resource delivers it)
fixed-by:
amended-design: 03-DESIGN/01-to-be/05-the-node-host.md
---
# 142 — The host is the one thing the mesh does not deliver
## What was observed
A change to the host was merged and could not reach any machine without a person copying a file.
Checked on the mesh of four machines, 2026-09-29:
- **The host is not a build target.** Asked what had been built for it, the control plane answered
`nothing has been built for mesh-host`. A merge on the forge builds every changed module and the
control plane itself, because the control plane is a module. The host is not one, and nothing
builds it.
- **No declaration delivers it.** No resource kind names an executable to place on a machine, and
nothing on a machine fetches one.
- **The half that recovers from a bad host exists and is unused.** `internal/upgrade` can report that
the executable this process started from has been replaced on disk, and records which version last
completed a reconcile so a shell script can roll back a host that will not start. The launcher reads
that record and rolls back. But `Replaced()` is called by nothing except its own tests — the
recovery is wired and the delivery was never built.
- **Every machine runs a byte-identical binary, stamped by hand.** All four carry the same size and
the same timestamp, from the last time somebody built it on a workstation and copied it out. No
package owns the file.
## Why it matters beyond this instance
**The component that implements updating is the one thing not updated.** The mesh's stated shape is
that a push produces the right builds and they reach the machines running them with nobody asking. It
is true of every module and of the control plane. It is false for the host, which is what applies all
of them.
**It is a bootstrap problem being answered by a person.** The host cannot be an ordinary module
because the host is what applies modules; a module that replaces the thing applying it has to survive
its own replacement. That is a real difficulty, and the work already done — noticing that the
executable changed, recording a known-good version, a launcher that rolls back — is the hard half of
solving it. What is missing is the easy half, and its absence makes the hard half dead code.
**A hand-copied binary has no record anywhere.** Nothing says which version a machine runs, so
nothing can say a machine is behind, and the mesh's own account of itself — every machine current with
its source — cannot include the host. Four machines agreeing today is luck, not a property.
**And it silently gates any change that starts in the host.** A change that needs the host to report
something new cannot be rolled out by merging it: the control plane must wait for a person, and until
then it either refuses what depends on the new report or renders something wrong. That cost is paid by
every future change of this shape, and it was paid today.
## Open questions
- How is the host delivered without being applied by itself? A candidate shape: the host is built like
anything else, published as an artifact, and the *running* host fetches and stages the next one, then
stands aside — which is what `Replaced()` was written for and what the launcher's rollback already
covers.
- **Should this ride the bus, rather than becoming a mechanism of its own?** Everything else that
reaches a machine already does: a declaration is sent over it, a report comes back over it, and a
build announces what it produced on it, which is how a module's new version reaches the machines
running it. A host build announcing itself the same way, consumed by the host already running,
would make this the existing mechanism pointed at one more artifact rather than a second way of
delivering things. It would also give the machine somewhere to say which host it is running, on the
report it already sends.
- What records which version of the host a machine runs, so "behind" is answerable? Nothing does now.
- Does the host's version belong in its report, beside the other facts a machine states about itself?
- Who decides when a machine takes a new host — the mesh, on a build, or an operator per machine as
with converging? The rollback path means a bad host costs a reconcile rather than a machine, which
argues for the former.
- Does the same gap apply to the launcher and the units beside the binary, which are also files no
declaration names?
@@ -0,0 +1,73 @@
---
status: located
opened: 2026-09-29
located-in:
- mesh-controller internal/catalogue (a converged node's declaration)
- mesh-host internal/apply/opening.go
fixed-by:
amended-design:
---
# 143 — Converging a machine does not retire the firewall it found, and says it does
## What was observed
The control-node was converged on 2026-09-29, the first machine with a found firewall to be flipped —
the two converged before it had none.
The preview said, and the flip repeated:
```
the found firewall (ufw) is disabled, never flushed: its configuration stays on disk
...
sent: the host loads the mesh's filter and disables the firewall it found
```
The mesh then reported the node `converged`, 372 resources applied, nothing failed. Afterwards, on the
machine:
```
systemctl is-enabled ufw -> enabled
systemctl is-active ufw -> active
```
And the declaration that was sent carries no resource that would disable it. A converged node's
declaration has no adoption block at all, and nothing in its 372 resources names the found firewall.
The sentence is printed by the command; no resource implements it.
## Why it matters beyond this instance
**It is a stated behaviour that does not happen, reported as success** — the fault this repository
exists to catch, and
[ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md) states it as
part of what the flip *is*: "loads the mesh's derived filter in place of its refusal-only table, and
retires the found firewall by disabling it, never by flushing".
**It could only be found on the first machine that had one.** The two machines converged before this
had no firewall to retire, so the step had never run, and nothing reported that it had not. That is
the same shape as [issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md):
a step that is silent when it does nothing.
**The machine is left doubly filtered, which is not what either firewall describes.** Every base chain
at a hook runs and a drop in any is final, so the machine now enforces the *intersection* of the mesh's
derived filter and a rule set left by the system being replaced. Nothing is broken by that today —
measured from outside, mail, the proxy and git-over-ssh answer and the databases and admin interfaces
are refused — but the machine's behaviour is described by neither of the two things claiming to
describe it, and the stale set includes a rule for a broker that no longer exists.
**And returning the node to adopted would be wrong in the other direction.** ADR 0100 says that
restores the found firewall by enabling it again; enabling something that was never disabled is
harmless, but the mesh's belief about which firewall is in force has been wrong in both modes.
## Open questions
- Which side owns retiring it — a resource in the declaration, so it is applied and reported like
everything else, or the flip as an act? A resource seems right: the flip is otherwise entirely
expressed as one, and an act that only the command performs cannot be re-checked on a later
reconcile.
- What should a reconcile do if the found firewall is enabled again by hand, or by a package update?
Convergence is a state, so presumably re-disable it and say so.
- Should the preview say what it *will* do rather than what it does, until a step exists that does it?
The wording was read as evidence twice in one session.
- Is there a check that a sentence the mesh prints corresponds to a resource it sends? This is the
second time today that a printed claim and a sent declaration disagreed.
@@ -0,0 +1,70 @@
---
status: located
opened: 2026-09-29
located-in:
- mesh-host internal/apply/opening.go
- mesh-controller cmd/mesh-controller (the converge preview)
fixed-by:
amended-design:
---
# 144 — A predecessor's rules outlive the firewall the mesh found, and the mesh cannot see them
## What was observed
The mesh reports one thing about a machine's existing filtering: `firewall found: ufw`. On the
control-node, ufw was never what filtered the traffic that mattered.
Measured on 2026-09-29, before the machine was converged:
- ufw filters connections *to the machine*. It does not filter connections to a container's published
port, which arrive on the forwarded path where the container runtime accepts them before ufw's
forward chains are reached. Around thirty ports were published that way.
- Every one of the mesh's own forwarded openings, converged through ufw, had matched **zero packets** —
fifty rules in that chain, none ever matched, while the chain itself had passed 1.6 million
established packets. The restrictions read as applied and were inert.
- What actually kept those ports off the internet was a chain the predecessor installed in the
container runtime's own pre-accept hook, allowing the deliberately public ports and the private
ranges and dropping the rest on the outward link. Confirmed from outside: the proxy answered, the
container manager did not.
- That chain exists only in the running kernel. The persisted rule file is the distribution's empty
default, and nothing on disk recreates the chain.
After the flip, the mesh's own filter is loaded and does cover the forwarded path, so the machine no
longer depends on that chain. But **the chain is still there**, and it is now the only thing refusing
two ports the mesh believes are open: the bus and the registry, which
[ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md) requires be
reachable from anywhere so a machine can enrol and pull before it has a private-network address. The
mesh's rendered filter accepts both from anywhere. From outside, both are refused.
## Why it matters beyond this instance
**"The firewall found" is a kind, and filtering is not all in one place.** The host identifies one
front-end and reports it. A machine can carry rules from several sources — the front-end's own, the
container runtime's, an intrusion-prevention chain, and whatever a predecessor installed directly —
and the mesh's account of what filters the machine names exactly one of them.
**So adoption's central promise was half-true in both directions.** What the mesh converged through
the found firewall on the forwarded path did nothing at all, and what did the work was invisible to it.
A machine was reported as filtered by a mechanism that was not filtering.
**And convergence cannot retire what it cannot see.** Even once
[issue 143](../143-converging-does-not-retire-the-firewall-it-found/00-report.md) is fixed and the found
firewall is disabled, this chain remains, silently narrowing the machine below what the mesh's own
filter says. A rule the mesh did not write, cannot list, and will not remove — which today breaks the
enrolment path the design guarantees.
**The safe direction is not the same as the correct one.** Being more closed than intended broke nothing
visible, which is exactly why it went unnoticed for as long as the mesh has been on this machine.
## Open questions
- Should the host report every place the machine filters from, rather than one kind — the front-end,
the runtime's hooks, and any chain it does not recognise, named so a person can look?
- What should the mesh do about rules it did not write and does not understand? Reporting them seems
right; removing them cannot be, and leaving them silent is what produced this.
- Does an opening converged through a found firewall need a check that it can actually take effect?
Fifty rules matching nothing would have been visible from the counters at any point.
- Is the bus and the registry being reachable from anywhere still what the mesh wants on a machine that
faces the internet? The design says yes, for enrolment. It deserves asking on its own rather than
being answered by a leftover.