Merge main: renumber this branch's records around the trunk's

Both lines of work numbered from the same point, so four decision records and one design
document existed twice with different content. The trunk keeps its numbers and this branch
yields — the only rule that scales, because the trunk's are already cited by what merged
before them.

  0117 the bus is the only broker        -> 0125
  0118 a module declares its own seats   -> 0126
  0119 amqp is a provision, not the bus  -> 0127
  0120 the mesh bus is required          -> 0128
  0123 a seat carries its role's protocol -> 0129
  0124 the predecessor is ending          -> 0130
  design 29, what a module declares       -> design 32

Applied to the code repositories too, because a stale reference is worse when numbers
collide than when they dangle: the reader lands on a real record that decided something
else.

Two reconciliations the merge forced, both real:

**0110 was marked wholly superseded and was not.** Its successor says in as many words that
everything 0110 decided about what a seat *is* stands untouched — and two records that
landed on the trunk rest on exactly that part. So it is accepted again, extended rather than
replaced, with a note saying which of its claims moved and where.

**A seat's protocol becomes columns, not fields.** The trunk moved the seat set out of
compiled code into a table the controller owns. This branch had added what a role accepts,
emits and serves to the Go slice. The decision is unaffected and the mechanism is better for
it: giving a role a protocol is now a write rather than a rebuild, which is the trunk's own
argument applied to what this branch added.

One check still fails and it fails on main too: a record resting on ADR 0112 while that is
still 'proposed'. Left alone — it is not this merge's to answer.
This commit is contained in:
2026-09-27 18:23:41 +02:00
36 changed files with 1489 additions and 104 deletions
@@ -89,7 +89,7 @@ three relationships, one broker, one runtime, all declared on the manifest.
>
> The decision is untouched — events are declared on both sides, 1:many, credential-free, and
> still provisioning's lighter sibling; the lightness is now relative rather than absolute.
> [ADR 0118](0118-a-module-declares-its-own-seats.md) adds the relationship this record's two
> [ADR 0126](0126-a-module-declares-its-own-seats.md) adds the relationship this record's two
> columns had no room for: work addressed to a role, where exactly one holder must act.
## References
+3 -3
View File
@@ -83,16 +83,16 @@ compatibility broker beside it moves its bus in one rollout with every node repo
> ["amqp"]` grant its provisioner answered with a private vhost — `amqp-ping` and
> `amqp-email-forwarder`. On the retirement condition below, both would have been left requiring
> something no provider answers.
> [ADR 0117](0117-the-bus-is-the-only-broker.md) resolves it by moving them onto the bus and
> [ADR 0125](0125-the-bus-is-the-only-broker.md) resolves it by moving them onto the bus and
> retiring the interface, which makes this record's sentence true rather than merely intended. The
> decision — the bus is NATS, the AMQP broker becomes the predecessor's compatibility broker and
> retires with the last of them — is unchanged.
> **Progressive insight — 2026-09-26, correcting the one above.** *The broker is not a
> compatibility module at all, and the sentence does not become true.* The insight above said
> [ADR 0117](0117-the-bus-is-the-only-broker.md) would make "one purpose — the predecessor's
> [ADR 0125](0125-the-bus-is-the-only-broker.md) would make "one purpose — the predecessor's
> clients" true by moving the mesh's own modules off it.
> [ADR 0119](0119-amqp-is-a-provision-not-the-bus.md) supersedes that: nothing moves off, because
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) supersedes that: nothing moves off, because
> a module may legitimately need an AMQP broker as a backing service the way it needs a database.
> The broker becomes **an ordinary provider module** — no seat, not foundation, not raised at
> genesis, and with no retirement condition, because the day its last client disappears is not a
@@ -1,7 +1,6 @@
---
topic: what runs on it
status: superseded
superseded-by: 02-DECISIONS/0118-a-module-declares-its-own-seats.md
status: accepted
date: 2026-09-25
deciders: jochen
reconstructed: false
@@ -10,6 +9,17 @@ extends: 0009-modules-and-the-graph.md
# 110. A seat is held by one assignment, from a closed set, and it may deliver a provision
> **Narrowed, not replaced — 2026-09-27, on merging two lines of work.** This was marked superseded by
> [ADR 0126](0126-a-module-declares-its-own-seats.md), and that overstated it: 0126 says in as many
> words that *"everything 0110 decided about what a seat is stands untouched"*. What moved is where the
> set lives and who may add to it —
> [0126](0126-a-module-declares-its-own-seats.md) lets a module declare one and makes the set derived,
> [0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) names the mesh's own
> for their scope, and [0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moves them out of
> code into a table. **What a seat *is* — one holder at its scope, a definition saying what a module
> can hold against an assignment saying what it does, a role made singular rather than a module — is
> this record and still current**, which is why those three rest on it.
## Context
[ADR 0009](0009-modules-and-the-graph.md) introduced claims: a module declares something
@@ -0,0 +1,135 @@
---
topic: what runs on it
status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
---
# 117. A machine's uplink is a seat: the mesh configures the manager, never the link
## Context
The mesh installs on top of a machine's own networking. The private network's generator says
so in as many words: a machine has an address and a route to the broker *before* the mesh
exists, the broker's address travels in the enrolment token rather than being resolved, and the
private network is something the mesh installs on top, like anything else. Nothing in the mesh
says who manages that uplink, or what the mesh needs from whoever does.
Adopting the first workstations showed that the mesh does need something from it, and gets it
by accident:
- **The resolver the mesh owns depends on a file the mesh does not.** `resolv-conf` writes
`/etc/resolv.conf` and names the mesh's resolver. On a machine running NetworkManager, the
manager rewrites that file on every connectivity change unless it is told `dns=none`; on one
running dhcpcd, every lease renewal rewrites it unless it is told `nohook resolv.conf`. On
the adopted machines both settings exist only because the predecessor wrote them. No module
declares them. Remove the predecessor's file and the mesh's resolver is silently replaced the
next time a laptop changes network, while every surface of the mesh still reads green.
- **`resolv-conf` cannot declare them itself.** Which setting is needed depends on which
manager runs, and a `service` resource for a manager that is not installed fails the
declaration. A resolver module that knew about network managers would be the wrong module
knowing the wrong thing.
- **The private network's interface is exposed to the manager.** A manager that considers
every interface its own may try to configure `mesh0`, or tear it down on a profile change.
Nothing tells it not to.
- **Two managers on one machine go unnoticed.** Among the machines adopted so far, one runs
NetworkManager *and* dhcpcd at once: two programs that each believe they own the machine's
addresses and its resolver file.
Nothing detected it, because nothing in the mesh knows the role exists.
The machines differ in a way that matters: servers are wired and never move, while
workstations join wireless networks, captive portals and phone hotspots wherever they are.
## Considered Options
**1. The mesh manages the uplink: links, addressing, wireless networks and their
credentials.** Rejected. The mesh reaches a machine only over that link. A declaration that
gets it wrong — a mistyped network, a stale credential, a manager that fails to start — takes
the machine off the network, and with it the only channel a fix could arrive on. That is the
one failure the sshd module's `listens` rule forbids the firewall to arrange; a mesh that owned
the link could arrange it with any push. And a wireless network is joined at the machine, by
the person using it, in the moment. A declaration composed elsewhere cannot answer a captive
portal.
**2. Leave the uplink unmanaged; accept the implicit dependency.** Rejected. It keeps the
resolver working only for as long as a predecessor's file survives, and it leaves two managers
on one machine undetectable.
**3. The uplink is a seat. The module holding it configures the manager's relationship to the
mesh, and never the link.** Chosen.
## Decision
**`the-uplink` is a node-scoped seat** in the closed set ([ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md)).
It delivers no provision. It is held by the module for the program that manages the machine's
own network, one per manager: `networkmanager`, `systemd-networkd`, and `dhcpcd` for a machine
with nothing more. Assigning a second is refused, naming the first.
**What a holder declares** — only what keeps the manager and the mesh from contradicting each
other:
- the manager's package, present — and its service **with no state**: the manager's lifecycle is
the machine's. The mesh never starts, stops, enables or disables it, because stopping it takes
the link down, and a holder unassigned by mistake — or the wrong holder assigned — must not be
able to do that, nor start a second manager beside the one the machine runs. The service is
declared only so a change to the holder's settings reaches a *running* manager;
- the manager's own configuration that leaves the resolver file to the mesh (`dns=none` for
NetworkManager, `nohook resolv.conf` for dhcpcd, and nothing for systemd-networkd, which
never writes the resolver file);
- the manager's own configuration that leaves the private network's interface alone
(NetworkManager's `unmanaged-devices` naming `mesh0`; dhcpcd's `denyinterfaces mesh0`; for
systemd-networkd a network file of the module's matching `mesh0` as `Unmanaged=yes`);
- each as a drop-in beside the manager's main file where the manager reads one, and written
*into* a shared file otherwise, as a marked region the host owns
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)'s idea for text files),
placed where the manager reads it as global — at the start of `dhcpcd.conf`, above any
`interface` line, because every line after one belongs to that interface;
- the service **reloaded** when a drop-in changes, never restarted — a restart drops the link,
and the link is the mesh's own channel to the machine. **A manager that cannot reload is not
restarted instead:** its setting takes effect at the manager's next start. Measured on the
adopted machines: NetworkManager (1.58) and systemd-networkd (systemd 261) both report
`CanReload=yes`; dhcpcd (10.3) reports `CanReload=no`, so its module declares no trigger at
all. Whether each setting is actually *applied* by a reload is confirmed on a machine before
the module is taken there, not assumed.
**What a holder never declares:** a link, an address, a route, a connection profile, a
wireless network or its credentials. Those are the operator's, in the sense of
[ADR 0051](0051-shared-data-is-the-operators.md): the mesh does not create, change or delete
them, and the module's `access`, if it needs one, is read-only.
## Consequences
- `resolv-conf` stays generic. The condition it could not express — "only if NetworkManager
runs" — is expressed by assigning the module for the manager that does.
- The dependency on the predecessor's `dns=none` file becomes a declared resource. On an
adopted node the holder's drop-in arrives beside the predecessor's; both say the same thing,
and the predecessor's is retired by hand after the take, like any other file the mesh
replaced under another name.
- A setting a manager reads only at its start is not in force until then. On an adopted machine
the predecessor's identical line normally already is; on a machine that was not adopted,
dhcpcd's resolver hook keeps rewriting the resolver file until dhcpcd next starts, and the
operator restarts it once, in a window of their choosing.
- A machine running two managers is found at assignment: the second holder is refused, and the
operator decides which manager the machine keeps before either module is taken.
- Workstations keep joining networks the way they always have. Under NetworkManager and
systemd-networkd the host already cooperates with the manager — its dispatcher hook wakes it
on every connectivity change — and nothing here changes that. A dhcpcd-only machine has no
such hook, and nothing here adds one.
- The seat table gains one entry: `the-uplink`, node scope, delivering nothing, decided here.
- **Not decided here:** whether the mesh should ever *offer* known networks to a machine — a
sealed, add-only list the operator curates once for all workstations. That is a different
question (the mesh holding credentials for links it must never be able to break) and gets its
own record if it is wanted.
## References
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): the closed set this seat joins;
[to-be 26](../03-DESIGN/01-to-be/26-the-seats.md): the seat table
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md): written into, never over
- [ADR 0051](0051-shared-data-is-the-operators.md): what is the operator's stays the operator's
- mesh-controller `internal/catalogue/seats.go` (the seat), `internal/overlay/generator.go` (the
mesh installs on top of the machine's own networking)
- mesh-catalog `modules/networkmanager`, `modules/systemd-networkd`, `modules/dhcpcd`
- mesh-host `internal/apply/block.go` (a file written into a marked region, `at` start or end)
@@ -0,0 +1,119 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 0102-the-mesh-writes-into-a-shared-file-never-over-it.md
---
# 118. Undeclaring removes what the mesh made, and gives a unit back the state it was found in
## Context
When a resource stops being declared — its module unassigned, the node sent a
deliberately-empty declaration ([issue 127](../04-ISSUES/127-a-declaration-that-shrinks-to-empty-is-skipped-not-sent/00-report.md)),
or a new catalogue version renaming its id — the host undoes it. The host's own code states
the rule it means to follow: **it removes what it made and leaves what it merely configured.**
For almost every resource it does exactly that:
- a container, a network, a process's unit, a directory it created: removed;
- a file it created: removed; a file it replaced: its kept original put back
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md));
- keys and list members it wrote into a shared file: given back as they were
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md));
- a package: left installed — the host cannot know it is unused;
- an operator's path it was given access to: never touched
([ADR 0051](0051-shared-data-is-the-operators.md)).
**A service is the exception.** A `service` resource never installs a unit: it puts one that
already exists — the distribution's, the operator's — into a state. Undeclared, the host stops
it. That contradicts the rule above, and in practice it is the most dangerous thing an
undeclare can do. Found reviewing the uplink modules
([issue 130](../04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md)):
- the private network declares the container runtime's unit only so a change to the registry
trust reloads it — unassigning the private network stops the runtime, and every container on
the machine, the mesh's and not;
- the sshd module declares the ssh daemon — unassigning it stops ssh, the lockout that module's
own `listens` rule forbids;
- the uplink modules would have stopped the network manager, taking the machine off the only
link the mesh reaches it by.
[ADR 0125](0117-a-machines-uplink-is-a-seat.md) answered that for its own modules with a
service declared with no `state`. Every other module that declares a unit it did not make is
exposed in the same way, and relying on each author to remember an opt-out is how the next one
is missed.
## Considered Options
**1. Undeclaring touches nothing on the machine.** Rejected. What the mesh made would outlive
the module that made it: a container nobody manages keeps serving and stops being patched; a
unit the mesh wrote keeps running a bundle nothing updates; a name collides when the module
is assigned again. An undeclare that leaves the mesh's own work behind is an orphan factory.
**2. Keep stopping services; make "leave it running" an opt-in per resource.** Rejected. It
keeps the dangerous behaviour as the default for exactly the units that matter most — the
runtime, the ssh daemon, the network — and each new module is one forgotten field away from a
machine that goes dark when it is unassigned.
**3. Never stop a unit the mesh did not create.** Rejected, found while implementing it. The
mesh's packet filter is a unit the distribution installed and the mesh started at converge;
returning a node to adopted ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md))
unloads it by undeclaring it. Never stopping it would leave the mesh's filter loaded beside the
predecessor's firewall re-enabled — the one rollback a converge promises, broken. Who wrote the
unit file is not the line; what the mesh *did* to the unit is.
**4. Give the unit back the state it was found in.** Chosen.
## Decision
**Undeclaring removes what the mesh made and gives back what it changed.** For a unit the mesh
did not create, what it changed is the unit's state, so that is what is given back: **the host
records the state it first found the unit in, and undeclaring returns the unit to it.**
- **Recorded once**, the first time the host applies the service — whether it was running, and,
where the declaration sets it, whether it was enabled at boot — and carried in the host's
record from then on. Later applies never overwrite it: by then the unit's state is the mesh's
doing.
- **A unit found running is left running.** The container runtime, the ssh daemon, a network
manager: running before the mesh arrived, running after it leaves.
- **A unit the mesh started is stopped again**, and one it enabled is disabled again — the packet
filter a converge loaded, which returning to adopted unloads.
- **Never started on the way out.** A unit the mesh stopped is not started again when its
declaration goes; starting something is a decision, and the operator makes it.
- **Unknown is left alone.** A record written before the host kept what it found says nothing
about the unit before the mesh; the unit is left exactly as it is. A unit left running can be
stopped by the operator; one stopped by mistake may be the link the operator needed to do it.
- A unit the mesh *did* create — a `process` resource's unit and bundle — is stopped and removed
with its declaration. That is the mesh's own code. (Before this record there was no way to
remove one at all: an undeclared process failed every apply on its node.)
- The service's settings the mesh wrote are given back by their own resources (a kept original
restored, a region or keys removed). A running service keeps running on what it read until it
next reads its configuration; the mesh does not restart it to make it notice.
- A service declared with no `state` (ADR 0125) remains the way to say the mesh must not
**start** a unit either; undeclared, it is forgotten.
## Consequences
- Unassigning the private network no longer stops the container runtime; unassigning sshd no
longer stops ssh; no uplink module can take a machine's network down on its way out.
- The host's removal report says what it gave back — "restored: stopped again, as the host
found it" — or "forgotten: it was running before the mesh; left as it is" where it used to say
"stopped". Its plan names each unit an undeclare will stop, before it does.
- On a fresh machine where the mesh installed and started a service, unassigning its module
stops it again — the mesh gave, the mesh takes back. An operator who wants it kept declares it
in a module of their own, or starts it themselves after.
- A daemon can keep running after its module is gone, on configuration that was taken back from
under it. That is a visible, running process the operator can see and stop; the alternative
was an invisible outage.
- **Not decided here:** an unassign preview that lists what an undeclare will remove and what it
will leave running. Issue 130 asks for it; it is the controller's to build.
## References
- [issue 130](../04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md): the finding
- [ADR 0125](0117-a-machines-uplink-is-a-seat.md): the uplink modules, and a service with no state
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md):
what is given back, and how
- mesh-host `internal/apply/apply.go` (`remove`, the service case)
@@ -0,0 +1,87 @@
---
topic: the mesh
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
---
# 119. A taken tunnel's predecessor is retired once the take is proven
## Context
[ADR 0105](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md) has the private network take
over the tunnel it finds: the found unit stopped and disabled, never flushed, and **its
configuration left on disk, kept like any held file.** That was the right caution for the take
itself — if the mesh's interface failed to come up, the host starts the found unit again and the
peers never notice — and every apply since stops the found unit again should anyone start it.
What it leaves is a predecessor that never finishes leaving. On every machine that has enrolled,
the tunnel is the mesh's and has been proven so — its interface up with the found key, the peers
handshaking, the machines resolving and reaching each other over it — and still the predecessor's
configuration sits where its unit reads it, held for a module that has long since replaced it.
The predecessor itself is being deprecated. A tunnel that can be started again by one command, with
a configuration nothing maintains any more, is not a rollback path; it is a second way onto the
network that nobody is watching. And the hold never ends, so every node report keeps listing it.
## Considered Options
**1. Keep it, as 0105 says.** Rejected: the caution it bought is spent once the take is proven, and
what remains is a live, unmaintained way back onto the network.
**2. Delete it at the take.** Rejected: the take is exactly the moment the fallback is needed. If
the mesh's interface does not come up, the host must still be able to raise the found one.
**3. Retire it once the take is proven.** Chosen.
## Decision
**Once the mesh's interface has proven it carries the tunnel, the found interface's configuration
is removed from where its unit reads it.**
- **Proven means:** the tunnel's state is *taken* — the found unit down and disabled, the mesh's
interface up with the found key — and the mesh's interface has completed a handshake with at
least one peer. Not before: until then, a failed take still falls back to the found unit.
- **Retired means:** the configuration file the found unit reads is removed. Its original was
already kept, before anything happened to it
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)), and stays kept; that copy
is the record of what the predecessor was, and a person's way back if one is ever wanted.
- The found unit stays disabled. Without its configuration it cannot raise the interface, so the
every-apply stop that guarded against it becomes a check that finds nothing to do.
- **The hold ends.** What was held for the private network has been replaced; the node stops
reporting it.
- **The mesh never brings it back.** Undeclaring the private network does not restore the found
tunnel: the mesh stopped it, and nothing is started on the way out
([ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)). A machine whose
private network is unassigned has no tunnel until it is assigned again — which is what
unassigning it means.
## Consequences
- On every machine that took a tunnel, the predecessor's tunnel configuration disappears at the
first apply after the take is proven. Nothing a peer sees changes; the mesh's interface already
carries the same key, port, address and peers.
- A take that is never proven — no peer ever handshakes — keeps the found configuration, and the
node says so, so a broken take is visible rather than silently retired.
- Rolling back to the predecessor's tunnel becomes a deliberate act, in this order: **unassign the
private network first**, then copy the kept original back and start its unit. The mesh does
neither. While the private network is still assigned, the tunnel is the mesh's: a restored
configuration is held and retired again at the next proven apply, and the found unit cannot
bind the port the mesh's interface holds. The node says so when it happens.
- A configuration something keeps writing back — the predecessor's own tooling, say — is retired
again each time it appears, but the first original stays the one kept; a different content is
kept once beside it, and the node reports that the configuration came back.
- The host retires only the found interface's own configuration file (`/etc/wireguard/<iface>.conf`),
never a path the mesh writes, and never a link: a configuration that is a link to somewhere else
is left, with its target, for a person to retire.
- 0105's "its configuration stays on disk, kept like any held file" holds until the take is proven,
and not after.
## References
- [ADR 0105](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md): the take, and why it keeps
the found configuration during it
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): kept originals
- [ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): nothing is started on the way out
- mesh-host `internal/apply/takeover.go`
@@ -0,0 +1,136 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
---
# 120. A roster fact carries its format as a template: the mesh owns the data, the module owns the format
## Context
A **fact** is a thing only the mesh knows — which machines exist, what they are called, where they
are — written into a file where a module asks for it. The mesh computes it from the graph; a module
loads it, restarts on it, does what its software does with it. Facts replaced three modules that
existed only because computed output needed somewhere to live and ran no software of their own
([ADR 0040](0040-what-a-module-is.md)).
But the *format* lived in the control plane. A fact was a name from a closed list, and each name had
a formatter written in Go beside the others: `node-names` wrote the roster as an `/etc/hosts` file,
`node-zones` wrote it as a dnsmasq resolver's `local=`/`address=` lines. Adding a consumer meant
adding a formatter — in the consumer's own configuration language — to the mesh.
The ssh work made the cost plain. An operator's `~/.ssh` wants three roster projections — a
`known_hosts`, an ssh `config` of `Host` blocks, an `authorized_keys` — each in ssh's syntax. Under
the closed list that is three more formatters in the control plane, teaching it ssh's configuration
language. And it does not stop at ssh: every daemon that reads the roster in its own file format
would put its grammar here. The control plane was accreting the configuration languages of software
it does not run — the exact thing [ADR 0040](0040-what-a-module-is.md) says is a
module's and not the mesh's.
The shape underneath is one shape. WireGuard's `[Peer]` blocks, `/etc/hosts`, dnsmasq's zones, an
ssh `known_hosts` — all of them are *the roster, projected into a file*. Only the projection differs,
and the projection belongs to whoever runs the software that reads it.
## Considered Options
**1. Keep the closed list; add a formatter per consumer.** Rejected. The control plane learns the
configuration language of every daemon any module might run, without bound, and each format lives in
the mesh rather than in the module that owns the file. A module cannot change how its own file is
written without a control-plane change.
**2. A general placeholder vocabulary over `content`, like `${machine:address}` but for the
roster.** Rejected. The mesh's other substitutions each resolve to *one* scalar — this machine's
address, one provider's port. The roster is inherently a *repetition*: one block per machine. A flat
`${…}` vocabulary cannot iterate, and a mechanism that could would be a template in all but name.
**3. The module gives a path and a template over the roster; the mesh renders it.** Chosen. The mesh
owns the data — who exists, their names and addresses — and hands it to a Go `text/template` the
module wrote. The mesh renders and reads neither the template's intent nor the file's meaning.
## Decision
**A fact is a path and a template.** In a module's manifest, `facts` maps a name the module chooses
to a `{ path, template }`. The template is a Go `text/template` over a fixed **roster view**:
- `.Node` — this machine's bare name.
- `.Suffix` — what a mesh name ends in (`internal`, or the operator's choice), as composed.
- `.Names` — every name the mesh serves: the machines *and* the names it was told to route.
- `.Machines` — only the machines that are nodes of this mesh.
Each of `.Names` and `.Machines` is a list of `{ Name, FQDN, Address }`. A machine the mesh has a
record for but cannot yet place has no address and is left out of both — a name that resolves to
nothing is a connection that hangs, so it is omitted rather than written (the same rule as before).
**The mesh owns the data; the module owns the format.** The control plane holds **no** formatter.
The two built-in projections render through the same path any module uses:
- **`/etc/hosts`** is a template on the mesh's own network module. The mesh writes `/etc/hosts`
because being on the private network is what gives a machine a name — but the *layout* is a
template like any other, shipped with the control plane because that module ships with it, not
because the control plane knows the hosts-file format.
- **dnsmasq's zones** move into dnsmasq. The `local=`/`address=` grammar is dnsmasq's configuration
language, and it now lives in dnsmasq's manifest, where the module that runs dnsmasq owns it.
**A fact also says whether its file is the mesh's whole or a region of the machine's.** A hosts file
is the machine's — its `localhost`, the operator's lines, another tool's marked blocks — so
`node-names` is `shared`: the mesh owns only its region and keeps the rest byte for byte, the host
laying it down `into: block`
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), hq issue 128). A resolver's
zones file is the mesh's whole, and is not shared. The template renders the content either way;
`shared` decides how the host writes it. This composes with hq 128 rather than replacing it: the
region *mechanism* is the host's, the region's *format* is the module's template.
**The names-vs-machines distinction is the template's choice** ([04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md)):
a container's hosts ranges `.Names`, so a routed name resolves to the machine serving it; a resolver
told the mesh's suffix is its own ranges `.Machines`, or a routed name written there with the suffix
appended is a name nobody will ever ask for.
**A template that will not render is refused at composition, not on a machine.** A template that does
not parse, or reads a field the roster does not have, fails where the manifest is — the closed-list
safety, moved from the fact's *name* to the roster's *shape*. A daemon that starts, reads a file the
mesh could not render, and answers nothing is a much worse way to find out.
**WireGuard stays a computed generator, and that is the line.** Its `mesh0.conf` is not a pure roster
projection — it carries topology the control plane decides: which peers are reachable, endpoints, hub
forwarding, keepalive for a NAT'd node. And it is *foundational*: the overlay must be up before any
module can be delivered, so the thing that writes it cannot itself be a delivered module. The line
this draws: **the substrate that delivery rides on is the control plane's; everything layered on a
working overlay is a roster template.** DNS, hosts, and ssh are layered; the overlay is the floor.
## Consequences
- **ssh is two templates and no control-plane change.** Once the roster view carries a machine's ssh
host key and its operator account ([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)),
`known_hosts`, the ssh `config`, and `authorized_keys` are templates on the ssh modules — the mesh
gains no knowledge of ssh's syntax. This ADR is what makes that work land without touching the
controller.
- **A new roster projection never touches the control plane.** Any module that reads the roster in
its own format ships its own template.
- **A module can change how its own file is written** without a control-plane change — it is editing
its own manifest.
- **The schema changed and is not backward compatible.** A fact was a string (a path); it is now
`{ path, template }`. The old string form has no template and cannot be auto-upgraded, because the
format it implied was the formatter this ADR deletes. The controller and every catalogue module
using facts — only dnsmasq — land together. A controller and a catalogue that disagree cannot
compose the module: the running daemon on a machine is unaffected, but the mesh will not send it a
new declaration until both sides agree.
- **The output did not change.** The `/etc/hosts` and dnsmasq zones a machine receives are
byte-for-byte what the deleted formatters wrote, pinned by tests that render the built-in template
and compose the real dnsmasq manifest.
## References
- [ADR 0040](0040-what-a-module-is.md): a module is software the mesh runs — a
format the mesh knows for software it does not run was the accretion this stops
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a module definition names no
path; this is its sibling for content — a module definition names no format the mesh must know
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md): the ssh consumer this
unblocks, and the roster fields it will add
- [04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md): every served name is not a
machine — now the template's choice of `.Names` or `.Machines`
- mesh-controller `internal/catalogue/roster.go` (the mechanism), `internal/overlay/generator.go`
(the built-in `/etc/hosts` template), `internal/catalogue/manifest.go` (`RosterFile`)
- mesh-catalog `modules/dnsmasq/module.json` (the zones template, dnsmasq's own)
@@ -0,0 +1,132 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
---
# 121. A system seat is named for its scope, and a module may define its own
## Context
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the
control plane defines: a well-formed name no longer becomes a seat by being claimed, so a person can
read what a mesh can have and who fills each role. It left two things unsettled that the growing set
now exposes:
- **The names carry no rule.** `mesh-controller`, `mesh-store`, `mesh-broker` are named for the mesh;
beside them sit `the-artifact-store`, `the-build-machine`, `the-dns-port`, `the-showcase`,
`the-uplink` — a second naming style with no principle behind it. A reader cannot tell a seat's
scope from its name, and the mesh's own roles do not look like the mesh's.
- **The set is the *only* place a seat may be defined.** A module claiming any name not in the
control plane's set is refused. That is right for *system* roles — one broker, one packet filter
per node — but it means a module can never define a role of its own: a demo module's
`the-showcase`, a future application's coordination role, must be smuggled into the control plane's
set or not exist. The control plane ends up holding roles that are not the mesh's to define.
Reviewing the set against these also found seats whose *scope* or *membership* is wrong, not just
their name — the review is the occasion to fix those too.
## Decision
**A system seat — one the control plane defines — is named for its scope:**
- **`mesh-*`** for a mesh-scoped seat: one holder in the whole mesh, a role the mesh has once
(`mesh-controller`, `mesh-store`, `mesh-broker`, `mesh-git`, …). A `mesh-*` seat is always held by
a module **on a named node** — `mesh-git` is gitea *on novox*, not "gitea"; another node running
gitea does not hold `mesh-git` unless it is the holder. The seat is the mesh's single answer for
the role, and which node answers is part of what the seat records.
- **`node-*`** for a node-scoped seat: one holder per node, a role each machine has at most once
(`node-packet-filter`, `node-intrusion-prevention`, `node-uplink`, …).
The three already-`mesh-*` seats keep their names; the rest are renamed by this rule. The scope a
name declares must match the seat's actual scope — a `mesh-*` seat at node scope, or the reverse, is
a contradiction the reader is entitled to trust is impossible.
**The control plane defines only system seats. A module may define its own.** A seat named `mesh-*`
or `node-*` is the control plane's, and claiming one the control plane does not define is refused as
before. Any *other* name is a **module-defined seat**: valid when the module declaring the claim also
declares the seat (its name, scope, and — if any — the protocol its holder speaks). The control plane
enforces one-holder-per-scope for it exactly as for its own, but does not otherwise know what it
means. So an application can coordinate its own instances through a seat of its own, and the mesh's
closed set stays what its name says it is: the *system's* roles, not everyone's.
**Specific seats this settles:**
- **`the-build-machine` → `mesh-build-machine`, and its scope becomes mesh.** There is one build
machine in the mesh (the builder on novox), not one per node. Node scope said the opposite. It
delivers no provision; it is the mesh's single build machine.
- **`the-private-network` → `mesh-private-network`, held by the network *server* on one node.** Today
it is node-scoped and held on every node, with a stated (untested) story that a different VPN could
hold it per machine — which would force every provider module to independently implement receiving
and applying the controller-composed configuration. The mesh does not work that way and should not
pretend to: **one mesh decides one private network.** The seat is mesh-scoped, held by the server
module (WireGuard on the hub, novox). A machine that joins is given a **client module** that
receives the composed configuration and applies it; when a node joins, the mesh emits each node's
configuration so all of them know each other at once. This drops per-node VPN choice deliberately —
the private network is nox-mesh's own, and it defines the nodes' configuration rather than being
assembled from each node's opinion. (Implementation: the overlay generator's per-node computation
is unchanged; what changes is the seat's scope and the server/client split of the module.)
- **`the-showcase` → removed from the set; it becomes a module-defined seat.** It is a demo module's
own coordination role, claimed by nothing else and held nowhere. It is the first module-defined
seat, and the reason the rule above is needed rather than hypothetical.
- **`the-dns-port` → `node-dns-resolver`** (the daemon that binds `:53`), kept distinct from
**`the-resolver-configuration` → `node-resolver-config`** (what writes `resolv.conf`). Two roles,
two seats; the rename must not blur them.
- **`the-packet-filter` → `node-packet-filter`** and **`the-intrusion-prevention` →
`node-intrusion-prevention`** — names kept as-is but for the prefix. "Packet filter" stays distinct
from "firewall", which would swallow intrusion-prevention too.
- **`the-uplink` → `node-uplink`** ([ADR 0125](0117-a-machines-uplink-is-a-seat.md)). Unheld, so it
renames with no migration.
- **The registry seats — `the-artifact-store`, `npm-package-registry` (→ `mesh-artifact-store`,
`mesh-npm-package-registry`) — and `git` (→ `mesh-git`) — are decided but deferred.** They each
*deliver* a provision, so renaming them is a delivering-seat migration: a holder that stops
resolving mid-flight takes a provision away from every consumer. That risk is not worth carrying in
the same pass as the node-* renames, so they keep their names until done deliberately.
**`distribution` stays the mesh's registry; only `verdaccio` is retired.** An earlier draft of this
record had the registry consolidating onto gitea and `distribution` retired — that was reversed:
`distribution` is the standalone OCI registry serving every `artifact-store://…@sha256` image (the
control plane's own included), and the mesh keeps it. `verdaccio` was a *second* npm registry;
gitea already provides `npm-package-registry`, so verdaccio is redundant and is removed. It is only
in the catalogue (never registered in the running mesh), so removing it is deleting the module — no
migration, nothing to strand.
## Consequences
- **A reader learns a seat's scope from its name.** `mesh-*` is mesh-wide and one; `node-*` is
per-machine. The mesh's own roles finally look like the mesh's.
- **Applications get their own seats** without the control plane learning their meaning. The closed
set shrinks to what it should be — the system's roles — and stops being where unrelated roles hide.
- **The renames are a coordinated migration, not a rename.** A held seat's name lives in three places
that must move together: the control plane's set (`seats.go`), every claiming manifest, and what
each node reports it holds (re-derived by re-registering the manifest and re-pushing). A seat
renamed in one place and not the others stops resolving to its holder — and for a *delivering* seat
(`mesh-store`→postgres, `mesh-broker`→amqp, the registry seats) that is a mesh-wide provision
outage, the same failure mode as a schema change hitting an old manifest. So: the non-delivering
`node-*` seats and `mesh-build-machine` migrate as one tested controller+catalogue change;
`node-uplink` is free (unheld); the delivering registry seats are deferred to their own pass.
- **The node-* migration was done as one controlled step, and it froze briefly.** Deploying the new
controller made it reject the still-old-named claims in the stored manifests, so composition stopped
for the affected nodes until each manifest was re-registered under its new name; running services
were untouched, and the window was seconds. This is the coordinated-migration cost named above,
paid once — and the reason the *delivering* registry seats, whose freeze would be a provision
outage rather than a compose pause, are not folded into the same pass.
- **`distribution` is not retired.** It stays as the registry; only `verdaccio` (a redundant second
npm registry) is removed. The mesh keeps one OCI registry (`distribution`) and gitea for npm/git —
the "one registry, on gitea" idea was considered and dropped.
- **The private network stops pretending to be swappable per node.** The gain is a coherent
server/client model matching how the controller already composes configuration; the cost is that
choosing a different VPN is now a mesh-wide change, not a per-node one — accepted.
## References
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — the closed set this refines
- [ADR 0125](0117-a-machines-uplink-is-a-seat.md) — `the-uplink`, renamed here to `node-uplink`
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the original `mesh-*` seats
whose naming this generalises
- [to-be 26](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, updated by this
- mesh-controller `internal/catalogue/seats.go` (the set and claim validation),
`internal/overlay/generator.go` (the private network as server + client)
@@ -0,0 +1,107 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
- 0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
---
# 122. A seat is data the controller owns, and a rename is a database update
## Context
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made the seats a closed set the
control plane defines, and [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
named them by scope. Both were right about *what* a seat is. Both left it defined the wrong *way*:
**the set is a hardcoded Go slice compiled into the controller, and everything references a seat by
its name as a string literal.** Renaming `the-packet-filter` to `node-packet-filter` this session
took, in one pass:
- an edit to the Go slice in `internal/catalogue/seats.go`, recompiled into a new controller image;
- an edit to a `const gitSeat = "git"` in *production* control-plane code (`source.go`), because a
seat's name was hardcoded where a repository's home is resolved;
- edits to every claiming manifest in the catalogue, each re-registered;
- a controller **rebuild and redeploy**, which — because the running controller then refused the
still-old-named claims in stored manifests — **froze composition** for the affected nodes until
each manifest was re-registered under its new name;
- the same coupling in the **build machine**, which embeds the same seat set and refused to build
anything claiming a name it did not yet know;
- a **deadlock** when the build machine's own seat was renamed, since the old builder could not
build the new builder whose manifest claimed a name it rejected.
None of that is what a rename should cost. A rename is the operator changing a label. It should be a
single write, and nothing should have to be rebuilt, refused, or unfrozen. The set being *closed*
(0110) and *named by scope* (0121) are good rules; **the set being code is the mistake.** When
adhering to the design means twenty steps and a `const` in the resolver, the design is what to fix.
## Decision
**The seat set is data the control plane owns, not code it is compiled from.** The seats live in a
table in the controller's store — one row per seat: a **stable id**, a `name`, a `scope`, what it
`delivers` (a provision, or nothing), and the record that decided it. The rows are seeded by a
migration (the closed set 0110 defines still ships with the mesh), and thereafter they are ordinary
data the control plane reads and writes.
**A seat is referenced by its stable id, never by its name.** A claim, a held-seat record, and any
control-plane code that must name a seat (the git-seat resolver, the artifact-store guard) hold the
**id**. The `name` is a label for people and for what a manifest writes; it is resolved to an id
once, when a claim is registered. So:
- **A rename is one `UPDATE seats set name = … where id = …`.** Nothing is recompiled, nothing is
re-registered, nothing is refused, nothing freezes. Held records and claims already point at the
id, so they follow the rename for free. The build machine is not involved, because the build
machine validates a claim against the set it reads from the mesh, not one baked into its image.
- **Adding or removing a seat is an `INSERT`/`DELETE`** (within the closed-set discipline: a change
to the set is still a decision with a record — the record is now a row's `decided` column and an
ADR, not a line of Go). No controller release is needed to change the roster of roles.
- **Production code stops hardcoding names.** `const gitSeat = "git"` becomes a lookup of the seat
that delivers the `git` provision (or a well-known id), so renaming its label cannot break the
code that finds a repository's forge.
**What does not change** (0110 and 0121 still hold): a seat is still a module assignment from a
closed set; there is still one holder per scope; a delivering seat is still the single answer for
its provision; system seats are still `mesh-*`/`node-*` and a module may still define its own. Only
their *storage and reference* change — from a compiled slice keyed by name to a table keyed by id.
**A manifest still claims by name, and that is fine.** A manifest is written by a person and names
the seat in words; the mesh resolves the name to an id at registration and stores the id. If a
seat's name changes, manifests written against the old name are updated in the catalogue like any
other edit (and the mesh can keep the old name as an alias row during a transition so nothing breaks
in the window) — but the *control plane* never has to change or redeploy for it, which is the whole
point. The heavy, mesh-wide, freeze-prone half of a rename disappears; only the ordinary catalogue
edit remains.
## Consequences
- **A rename, and a set change, become operations, not releases.** The pain this session paid —
three freezes, a builder deadlock, hand-resolved manifests — is designed out. The seat migrations
still outstanding (the delivering registry seats, and the private network's scope change) should
wait for this: done as data, each is a write, not a coupled multi-repo deploy.
- **The controller gains a small table and a seed migration**, and its seat lookups change from
slice scans to id-keyed reads. `SeatNamed`, `SeatDelivering`, `claimProblems` read the table.
- **The build machine reads the set from the mesh** (it already talks to the control plane), rather
than embedding it — which removes the controller/builder seat coupling that made every breaking
seat change a two-sided deadlock (see [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md)).
- **The closed set is still closed.** Data being editable is not the set being open: changing it is
still a decision, still recorded. What changes is that recording it no longer means shipping a
binary.
- **This is a real refactor**, touching the store schema, the seat lookups, claim registration
(name→id resolution), and the held-seat records. It is worth its own build; until it lands, the
current compiled set stands and further renames are held rather than forced through the heavy path.
- **Config on a seat is still the module's** (the question that surfaced this): a seat row carries
the seat's own metadata (scope, delivers, protocol), not a module's configuration — that stays in
the holding module's manifest ([ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)). Making
seats data does not make them a config store.
## References
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md),
[ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the seat
rules this keeps, whose *storage* it changes
- [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md) — the controller/builder
seat coupling and the breaking-change freeze this removes for seat changes
- mesh-controller `internal/catalogue/seats.go` (the compiled slice this replaces),
`cmd/mesh-controller/source.go` (`const gitSeat`, the hardcoded name this removes)
@@ -1,14 +1,14 @@
---
topic: the mesh
status: superseded
superseded-by: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md
superseded-by: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0106-the-bus-is-nats.md
---
# 117. The bus is the only broker
# 125. The bus is the only broker
## Context
@@ -4,10 +4,10 @@ status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
supersedes: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
extends: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
---
# 118. A module declares its own seats; the mesh reserves its own
# 126. A module declares its own seats; the mesh reserves its own
## Context
@@ -18,7 +18,7 @@ controller's own code, and when that enumeration was done by hand while writing
reported eleven claims where there were thirteen.** The fix was a table in the controller, and
adding a seat became a decision.
What that table cannot express is the architecture [ADR 0117](0117-the-bus-is-the-only-broker.md)
What that table cannot express is the architecture [ADR 0125](0125-the-bus-is-the-only-broker.md)
opened. With one bus and no private brokers, a module offering a service to other modules offers
it as **a role on the bus**: a set of subjects, exactly one holder, addressed by what it does
rather than by which module or node provides it. A telegram sender, a licensing master, anything
@@ -139,7 +139,7 @@ protocols is the failure nobody could diagnose afterwards.
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — superseded here; its
requirement is kept and only its mechanism replaced.
- [ADR 0117](0117-the-bus-is-the-only-broker.md) — one bus, which is what makes a role addressable.
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — one bus, which is what makes a role addressable.
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the naming convention
the reserved prefix restores.
- [ADR 0041](0041-events-are-a-relationship.md) — the event half of the boundary drawn here.
@@ -4,14 +4,14 @@ status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
supersedes: 02-DECISIONS/0117-the-bus-is-the-only-broker.md
supersedes: 02-DECISIONS/0125-the-bus-is-the-only-broker.md
---
# 119. AMQP is a provision, not the bus
# 127. AMQP is a provision, not the bus
## Context
[ADR 0117](0117-the-bus-is-the-only-broker.md) decided that the bus is the only broker, and went
[ADR 0125](0125-the-bus-is-the-only-broker.md) decided that the bus is the only broker, and went
one step further than it had grounds for: it also decided that the `amqp` **interface** — a module
requiring a message broker of its own — "is not carried forward" and "retires with the
compatibility broker rather than gaining a successor", with the two modules declaring it converted
@@ -95,9 +95,9 @@ it is removed. They require a backing service and a provider answers.
## References
- [ADR 0117](0117-the-bus-is-the-only-broker.md) — superseded; its ruling on the bus is kept
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — superseded; its ruling on the bus is kept
whole and only its ruling on the interface is reversed.
- [ADR 0106](0106-the-bus-is-nats.md) — the bus is NATS; its compatibility-broker framing is
corrected here.
- [ADR 0118](0118-a-module-declares-its-own-seats.md) — seats, including the one the NATS server
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — seats, including the one the NATS server
now holds alone.
@@ -4,14 +4,14 @@ status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md
extends: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
---
# 120. The mesh bus is required, not ambient
# 128. The mesh bus is required, not ambient
## Context
[Design 29](../03-DESIGN/01-to-be/29-what-a-module-declares.md) opened by saying the bus is
[Design 29](../03-DESIGN/01-to-be/32-what-a-module-declares.md) opened by saying the bus is
*ambient*: "No module requires it, the way no module requires a filesystem. Every module gets a
connection and an identity whether it asks or not."
@@ -26,7 +26,7 @@ provisioning already does well: a consumer names where a credential lands, the m
there, and rotation and removal follow the same path as every other credential.
**The argument that made the bus ambient was narrower than it looked.**
[ADR 0117](0117-the-bus-is-the-only-broker.md) reasoned that bus accounts cannot be provisioned
[ADR 0125](0125-the-bus-is-the-only-broker.md) reasoned that bus accounts cannot be provisioned
because a provisioner is itself a module that needs an account before it can run. That is true of
a **provisioner process**, and it is not true of a provision: the mesh's bus accounts are composed
by the *controller*, into configuration, and the controller is not waiting on a bus account to
@@ -65,14 +65,14 @@ grants no subject; declaring a subject without requiring the bus is refused at r
incoherent.
**The `mesh-bus` provision is answered by the controller, not by a provisioner.** This is the
surviving kernel of ADR 0117's bootstrap argument, narrowed to what it actually supports: the
surviving kernel of ADR 0125's bootstrap argument, narrowed to what it actually supports: the
bus's accounts are configuration the controller composes and the server reloads
([ADR 0106](0106-the-bus-is-nats.md) — never through a management API), so there is no provisioner
process in the path and nothing waiting on a bus account to create bus accounts. It is a provision
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 0119](0119-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)), 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
@@ -83,7 +83,7 @@ is legitimate: a private bus is a backing service, never a channel to another mo
- **Design 29's opening is reversed.** The bus is not ambient; it is required, and the document's
first paragraph says the opposite of this.
- **The seat's `Delivers` is `mesh-bus`** — corrected twice in one day, which is worth recording
rather than tidying: it read `amqp`, which was the old broker's interface; ADR 0117 emptied it,
rather than tidying: it read `amqp`, which was the old broker's interface; ADR 0125 emptied it,
on the reasoning that a bus cannot be provisioned; and it is neither. The seat delivers the
mesh's bus.
- **`own-secrets: { broker: ... }` is retired** in favour of the provision's own delivery, across
@@ -107,9 +107,9 @@ is legitimate: a private bus is a backing service, never a channel to another mo
## References
- [ADR 0117](0117-the-bus-is-the-only-broker.md) — superseded by 0119; its bootstrap argument is
- [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 0119](0119-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) — 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,14 @@ status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0118-a-module-declares-its-own-seats.md
extends: 02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md
---
# 121. A seat carries the protocol of its role
# 129. A seat carries the protocol of its role
## Context
[ADR 0118](0118-a-module-declares-its-own-seats.md) let a module declare a seat with its protocol:
[ADR 0126](0126-a-module-declares-its-own-seats.md) let a module declare a seat with its protocol:
what work the role accepts, what it emits, what it serves. A module's own seats work that way today.
**The mesh's own seats — the `mesh-*` set — carry no protocol at all**, only a name, a scope and the
provision they deliver. They say who does a job and nothing about what may be said to them or by
@@ -71,6 +71,17 @@ the mesh has a word for a role.
any asker needs a grant across the whole inbox space, which is the one grant design 25 §4 refuses by
name. The seat's event costs the asker a filter and costs the mesh nothing.
## Reconciled with 0122, which landed in parallel
*Added 2026-09-27, on merging.* [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moved
the seat set out of compiled code and into a table the controller owns. This record was written against
the slice, and says the `mesh-*` set "gains the same three fields a declared seat has".
**The decision is unaffected and the mechanism is better for it.** What a seat accepts, emits and serves
becomes three columns beside its name and scope, so giving a role a protocol is a write rather than a
rebuild — which is the whole argument of 0122 applied to the thing this record adds. Where this text
says the set gains fields, read: the table gains columns.
## Consequences
**A seat is now the mesh's unit of "a role that talks".** A role that accepts work, announces outcomes
@@ -4,14 +4,14 @@ status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md
extends: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
---
# 122. The predecessor is ending, and its broker goes with it
# 130. The predecessor is ending, and its broker goes with it
## Context
[ADR 0119](0119-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) 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."*
@@ -28,7 +28,7 @@ because three documents reason from the premise it overturns:
## Decision
**The predecessor's broker retires when nothing requires `amqp`, by being unassigned like any other
provider.** No retirement condition, no end-date machinery, no special case — which is ADR 0119 being
provider.** No retirement condition, no end-date machinery, no special case — which is ADR 0127 being
paid off rather than revised. Because that record made the broker an ordinary provider, ending it
needs nothing that does not already exist: a provision with no consumers has its provider unassigned,
and the module system has done that since it existed.
+13 -7
View File
@@ -133,11 +133,12 @@ python3 00-META/checks/index.py fail if stale
- **0105** — [The mesh adopts the predecessor's tunnel in place](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md)
- **0106** — [The bus is NATS](0106-the-bus-is-nats.md)
- **0116** — [The bus is built in five steps, and the protocol moves with it](0116-the-bus-is-built-in-five-steps.md)
- **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) *(superseded)*
- **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md)
- **0120** — [The mesh bus is required, not ambient](0120-the-mesh-bus-is-required-not-ambient.md)
- **0121** — [A seat carries the protocol of its role](0121-a-seat-carries-the-protocol-of-its-role.md)
- **0122** — [The predecessor is ending, and its broker goes with it](0122-the-predecessor-is-ending-and-its-broker-goes-with-it.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)
- **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)
### Its tiers, from the bottom up
@@ -168,7 +169,7 @@ python3 00-META/checks/index.py fail if stale
- **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md)
- **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md)
- **0109** — [A package registry seat is one per ecosystem, not one for all of them](0109-a-package-registry-seat-is-one-per-ecosystem.md)
- **0118** — [A module declares its own seats; the mesh reserves its own](0118-a-module-declares-its-own-seats.md)
- **0126** — [A module declares its own seats; the mesh reserves its own](0126-a-module-declares-its-own-seats.md)
### What runs on them, and how it gets there
@@ -200,11 +201,16 @@ python3 00-META/checks/index.py fail if stale
- **0087** — [A seeded file is created once, and what grows in it is not the mesh's](0087-a-seeded-file-is-created-once.md)
- **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) *(superseded)*
- **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)*
- **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)*
- **0117** — [A machine's uplink is a seat: the mesh configures the manager, never the link](0117-a-machines-uplink-is-a-seat.md)
- **0118** — [Undeclaring removes what the mesh made, and gives a unit back the state it was found in](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)
- **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)
### How it is built