Compare commits

..
Author SHA1 Message Date
jschoubben e6b638e63c ADR 0167: a membership carries what its module receives, and who the mesh is
Issue 191's route proxy needs to know who the mesh is to serve an
internal name correctly, and the first fix had it work that out alone.
The membership on the bus now carries it, from the same list the filter
uses. ADR 0138 gains an insight that the proxy is where internal reach
is kept; designs 08 and 25 say how.
2026-10-02 01:48:03 +02:00
jschoubben 496d136d72 Issue 191: a route with only an internal name is dropped as naming nothing 2026-10-01 23:31:57 +02:00
26 changed files with 46 additions and 667 deletions
@@ -131,32 +131,6 @@ the plan says it too.
| Genesis raises the forge as its module declares it | a genesis test that raises, assigns, and finds the module holding rather than raising a second |
| Live | the next cutover on an adopted machine: `take` shows the comparison, refuses the downgrade if there is one, and the service keeps its configuration and its secret |
## Built, 2026-10-02
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
Built across mesh-host 63 and 64 and mesh-controller 201, 202 and the pull request that followed
them. Rule 1: `take` previews every held thing's comparison and ends with a digest; `take --yes
<digest>` acts on exactly that preview, and a changed preview or an account older than the flip
allows is refused, as the flip's are. A published port's reach is said as the machine reported it,
behind the found firewall whose rules are not read. Rule 2: an older image, a differing file and a
minted, unaccepted secret for found data refuse, overridden by `--downgrade`, `--replace <path>` and
`--mint <name>`; the secrets a module holds on a machine are read with where each came from. Rule 3:
`secret accept --provider` reaches a required secret. Rule 4: the per-machine setting is `networks`,
a container id to the found networks it keeps; judged for an adopted machine only, joined by the host
after the container runs, part of the container's spec, named in the preview. Rule 5: the host's
facts, former targets and strays. Rule 6: one judgement, run where a setting is stored and where a
machine is composed; a module whose stored setting its definition can no longer compose is left out
of the declaration, the envelope says so, the host keeps that module's things, and `plan` and `push`
say it by name. A key that reaches nothing is refused where stored and said by `plan`, and never
costs a module. Rule 7: genesis raises the forge under the module's container name, with its image
digest and its data directory; the network is the one difference left, said by the take, because the
bootstrap forge reaches the store on the machine's loopback.
**Not yet proven live.** Every machine of this mesh is converged, so the table's last row — a take
on an adopted machine — waits for the next adoption. What is live is what the rows above it check.
Issues 086, 098, 099, 100 and 101 stay located until that row is read.
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0104](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
@@ -1,120 +0,0 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
---
# 168. A converged machine is filtered by the mesh alone, and the host says what else refuses
## Context
[ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) says what converging does to
the firewall a machine was found with: the mesh's derived filter is loaded in place of the
refusal-only guard, and the found firewall is retired — disabled, never flushed. Four issues from
the first two convergences are four ways that sentence was not the machine:
- the flip reported the found firewall retired and it was active two minutes later; fifty minutes
on, a reconcile found it disabled by hand and recorded that the mesh had done it
([143](../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md));
- "the firewall found" named one front end, and what filtered the forwarded path on that machine
was a chain a predecessor had installed in the container runtime's user hook — invisible to the
mesh, refusing two ports the mesh declared open, and when it was removed, carrying an allowance
every module reaching another by the machine's own name had been relying on
([144](../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md),
[145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md));
- the forward chain listed address ranges that followed neither the modules nor the machine
([141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)), answered by
[ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md) before this record;
- the networking module wrote two machine-wide files whole, so taking it restarted every
container ([084](../04-ISSUES/084-taking-networking-on-an-adopted-node-restarts-every-container/00-report.md)),
answered by [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) and the hosts
file's marked region ([issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)).
Read on the four machines of this mesh on 2026-10-02, after every one had converged: on both
machines that had a front end it is inactive, and the host's record says the mesh retired it on
both — true of one, false of the other. On the home server the predecessor's chain is still in
force on the forwarded path, in the legacy packet filter the mesh's reader of rules does not
consult once a machine is converged, so that machine is filtered by two things and the mesh says
one. The host's reader already knows how to tell a table that refuses traffic from the runtime's
own plumbing and from a ban list; it is asked once, at adoption, and only to refuse a machine
whose firewall nobody speaks. Nothing asks it afterwards, and nothing reports what it saw.
The group's exit is one sentence: *a converged machine has exactly one thing filtering it, and
the mesh says truthfully which.* The first half the mesh can enforce only for what it owns; the
second half it can always do, and it is the half that was missing.
## Decision
**1. Convergence is a state the host keeps, not a step it takes once.** Every apply of a converged
declaration reads whether the found firewall is in force. Active — enabled again by a package, a
boot, a hand — it is retired again and said. The record distinguishes *the mesh disabled it* from
*it was found inactive*, and a reconcile that finds it inactive never records that the mesh did
it. When the step is skipped because the apply had failures, the report says the found firewall
was left in force and why; a step that does nothing is never silent.
**2. The host reports what filters the machine, with every apply, adopted or converged.** Every
table of the packet filter, and every chain of the legacy filter, that refuses traffic — a drop or
a reject, or a base chain whose policy drops — with an owner: the *mesh's*, the *found firewall's*,
the *container runtime's own*, a *ban* (a refusal that names the sources it refuses, in a chain
that accepts nothing), or *other*. The runtime's own is its plumbing — its chains, the forward
policy it sets when it turns forwarding on, its guard against reaching a container's address from
off its bridge. The user chain the runtime leaves for an administrator is not the runtime's:
anything refusing in it is *other*, which is where both predecessors' chains lived. Each entry
says in one line what it refuses. The mesh removes none of it: a rule it did not write is the
operator's to remove, now that they can see it.
**3. The mesh says which.** `node show` lists the filters with their owners. `status` names every
converged machine that something other than the mesh's table, the runtime's plumbing and a ban
list filters, the way it names strays and untaken modules, and such a machine is not "all well".
The converge preview lists the filters found and the fate of each: the found firewall retired, the
runtime's and the bans left, *other* left and named — so a person knows before the flip that the
machine will not be filtered by the mesh alone until they remove it, and what they would be
removing. *A converged machine is filtered by the mesh alone* when its list holds nothing but the
mesh's, the runtime's own and bans.
**4. Adoption's threshold does not move.** A machine whose front end nobody speaks is still refused
adoption; a refusing rule in the runtime's user chain still does not refuse it — on both machines
of this mesh it would have, and the migration would not have happened. It is reported instead,
from the first report on.
**5. Two of the group's issues are settled by records already accepted.** The forward chain follows
the machine's outward links and says nothing about networks ([ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md)),
which answers 141 whole. The runtime's file is written into and reloaded, and the hosts file's
region is the mesh's alone ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
[issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)), which answers 084. One
machine-wide file the mesh still writes whole is its own filter, at the path the distribution's
packet filter reads; an operator's own rules at that path would be contested, and are held as
found until the filter module is taken ([ADR 0163](0163-taking-a-module-over-is-a-comparison.md)).
That is a difference a take shows, not a fault, and is decided when it bites.
## Consequences
- The host's report grows by the filters it found and, for a converged machine, the state of its
found firewall and who retired it; the controller keeps both on the node's record.
- `retireFirewall` runs on every converged apply and can disable the found firewall more than
once; the record's *disabled by the mesh* means exactly that.
- The reader of rules gains an owner per table and chain; what it refuses adoption for does not
change. A ban stays what it was: not a firewall.
- Issues 143 and 144 close on rules 1 to 3 once a machine's record names the predecessor's chain;
141 closes on ADR 0140 and 084 on ADR 0102, both by reading.
- Removing what is reported is the operator's act, by hand, with the preview's words in front of
them. The mesh never flushes and never deletes a rule it did not mark.
## How this is checked
| Rule | Checked by |
|---|---|
| Every refusing table and chain is classified, the user chain's refusals as *other* | host tests over rulesets captured from three machines of this mesh: a predecessor's chain in the legacy filter, a ban list and empty front-end chains beside the runtime's, a virtualisation host and an endpoint agent that refuse nothing |
| The found firewall active again on a converged machine is retired again and said; found inactive is recorded as found, not done; a skipped step is said | host tests over a fake front end |
| The report carries the filters and the found firewall's state for a converged machine | a host test reading the report |
| `node show` lists filters with owners; `status` names a converged machine something else filters and is not well; the preview lists filters and fates | controller tests over a fixture report |
| Live | the home server's record names the predecessor's chain in the runtime's user chain as *other*; `status` names the machine; after the operator removes the chain, the next report drops it and `status` is well |
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0163](0163-taking-a-module-over-is-a-comparison.md)
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
- Issues 084, 141, 143, 144, 145
-1
View File
@@ -178,7 +178,6 @@ python3 00-META/checks/index.py fail if stale
- **0162** — [A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
- **0163** — [Taking a module over is a comparison: what it compares, what it refuses, and what it carries](0163-taking-a-module-over-is-a-comparison.md)
- **0167** — [A membership carries what its module receives, and who the mesh is](0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)
- **0168** — [A converged machine is filtered by the mesh alone, and the host says what else refuses](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)
### Its tiers, from the bottom up
+1 -23
View File
@@ -2,9 +2,8 @@
layer: to-be
status: in-progress
code: [mesh-host]
updated: 2026-10-02
updated: 2026-10-01
decisions:
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
@@ -164,27 +163,6 @@ a resource's former targets, removes a container or file it wrote under a name t
longer names, never removes what was found, and reports what runs on the machine that it neither
wrote nor holds. *How it is checked:* ADR 0163's table.
**What the host joins, keeps and raises for a take** — revision, 2026-10-02
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 4, 6 and 7). A
container may name networks it also joins once it runs — the found network a per-machine setting keeps
for a taken container while a neighbour still resolves it there; joined after the run, part of the
container's spec, refused when it cannot be joined. A declaration may name the modules the mesh left
out of it because a stored setting cannot compose with the module's definition: the host keeps what it
wrote and holds for a left-out module and says so, where absence used to read as removal. And genesis
raises the bootstrap forge under the forge module's container name, with the module's image digest and
its data directory, so the module holds it by the found rule; the network is the one difference a take
has left to say. *How it is checked:* a host test joins a kept network and refuses one it cannot; a
host test keeps a left-out module's record and hold and removes an absent module's; a bootstrap test
holds the installer's constants to the module's manifest where the catalogue is checked out beside it.
**What filters the machine, and the found firewall kept retired** — revision, 2026-10-02
([ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)). The host
reports, with every apply, every table and legacy chain that refuses traffic and whose it reads it as —
the mesh's, the found firewall's, the container runtime's own, a ban, or other — and, converged, whether
the firewall it was found with is in force and who retired it. It retires that firewall on every
converged apply, not once, records *found inactive* apart from *disabled by the mesh*, and says when the
step was skipped. *How it is checked:* ADR 0168's table.
**Found reaches every kind that can touch what the machine has**
([ADR 0103](../../02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md)). For a module not yet taken, a directory present with no record
keeps its mode and owner, a unit present with no record keeps its state and boot setting, a
-33
View File
@@ -9,7 +9,6 @@ code:
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-02
decisions:
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
- 02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md
- 02-DECISIONS/0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md
@@ -711,38 +710,6 @@ needs no new filter; a declared port is reachable from off the private network a
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.
### A converged machine is filtered by the mesh alone, and the host says what else refuses
*2026-10-02, [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md),
from [issues 143](../../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md) and
[144](../../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md).*
Retiring the found firewall was a step the flip took once, and said it had taken whatever happened;
on the first machine with one it did not take, and a hand's work fifty minutes later was recorded as
the mesh's. And "the firewall found" named one front end while a predecessor's chain in the container
runtime's user hook — legacy iptables on one machine, invisible to a reader of nftables — filtered the
forwarded path, refused ports the mesh declared open, and carried an allowance every module reaching
another by the machine's own name relied on.
**Convergence is a state the host keeps.** Every converged apply reads whether the found firewall is in
force; enabled again, it is retired again and said; the record says whether the mesh disabled it or
found it inactive, and a skipped step is said. **The host reports what filters the machine**, every
apply, adopted or converged: every table and legacy chain that refuses, with an owner — the mesh's,
the found firewall's, the runtime's own plumbing, a ban, or *other*, which is where the runtime's user
chain's refusals go. **The mesh says which:** `node show` lists them; `status` names a converged machine
anything *other* filters and is not well; the converge preview lists what filters the machine and the
fate of each — retired with the front end, left as the runtime's, left as a ban, or *left in force and
not the mesh's*. The mesh removes none of it; adoption's threshold does not move.
*How it is checked:* host tests over rulesets captured from three machines of this mesh classify every
refusing chain (a predecessor's chain in the legacy filter as *other*, a ban list reached through the
user chain as a ban, a leftover front-end chain as *other*); a fake front end enabled again on a
converged machine is retired again and said, found inactive is recorded as found; the report carries
the filters and the found firewall's state and a change in them is worth an unasked report; controller
tests over a fixture report check the recording, the preview's fates, the status JSON and the well
predicate. Live: the home server's record names the predecessor's chain as *other* and `status`
names the machine until the chain is removed by hand.
## 5 — Certificates
**Two authorities, kept separate on purpose.**
+1 -16
View File
@@ -8,7 +8,7 @@ code:
- mesh-host packaging/nox-mesh-host-network.sh
- mesh-controller internal/token
- mesh-controller internal/inventory/nodes.go
updated: 2026-10-02
updated: 2026-10-01
decisions:
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
@@ -323,21 +323,6 @@ network are said. `take --yes <digest>` cuts over what was previewed, as the fli
container may keep a found network by a per-machine setting while its neighbours are not yet taken.
*How it is checked:* ADR 0163's table.
**A setting is judged where it is stored, and the take's words** — revision, 2026-10-02
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1, 2, 4 and 6).
The preview ends with a digest of what it said; `take --yes <digest>` acts on that preview and nothing
else, and a preview that has changed since, or an account of the machine older than the flip allows, is
refused as the flip's is. A module the machine holds nothing for has nothing to compare, and `--yes`
suffices. The overrides are `--downgrade`, `--replace <path>` and `--mint <name>`; the per-machine
setting that keeps a found network is `networks`, a container id to the networks it keeps, accepted
for an adopted machine only. Storing a setting composes it against the module's current definition and
refuses, naming node, module, layer and key, what cannot compose or reaches nothing. A definition that
later moves under a stored setting costs that module its place in the machine's declaration, said by
name in `plan`, `push` and the declaration itself, and the machine is told everything else; a stray
setting no longer refuses the machine where it is read. *How it is checked:* controller tests over the
one judgement — refused where stored, a module left out where composed, the envelope naming it — and
over a take's digest, staleness and secrets.
A candidate machine is not empty. It has a package manager, probably a container runtime,
configuration somebody chose. [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
says the host never touches what it did not create — adoption is the deliberate act of taking
@@ -1,11 +1,8 @@
---
layer: to-be
status: in-progress
code:
- mesh-controller internal/inventory
- mesh-controller internal/catalogue
- mesh-controller cmd/mesh-controller
updated: 2026-10-01
status: proposed
code: []
updated: 2026-09-27
decisions:
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
@@ -17,10 +14,10 @@ decisions:
# 29 — A node has operator accounts, and the mesh owns what lives under a home
**The mesh models machines but not the people on them.** A node record holds its name, its
address, its mode — and nothing about *who a person is* on it: one login name on the build node,
another on the home-server, a third on both workstations. That username is not incidental. It
decides who a file under `~` is owned by, who a user service runs as, and — the case that surfaced
this — which account `ssh <node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
address, its mode — and nothing about *who a person is* on it: `jochens` on novox, `ace` on ace,
`jochen` on shanks and g14. That username is not incidental. It decides who a file under `~` is
owned by, who a user service runs as, and — the case that surfaced this — which account `ssh
<node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
person's `~/.ssh/config`, `~/.zshrc`, `~/.config`); the mesh, taking those over, kept the machine
facts and dropped the human one.
@@ -31,9 +28,8 @@ Several things are missing, and they are one idea.
A node has one or more **operator accounts**: the human logins on it. At minimum a name; the
mesh already knows the node and its address, so `<account>@<node>` is then a complete answer to
"who am I, where." It is the mesh's to hold because everything below is derived from it, and
because it is exactly the fact that was silently lost — `ssh home-server` logged in under the
workstation's own name, because nothing in the mesh said the home-server's account is a different
one.
because it is exactly the fact that was silently lost — `ssh ace` failed to `ace` because nothing
in the mesh said ace's account is `ace`.
## 2. A resource may live under a home, owned by its account
@@ -69,14 +65,14 @@ create `~/.ssh` at `0700`, chown it to the account, and own the files it places
**The boundary — and it is the reason this is safe:** `~/.ssh` is the one directory where a wrong
declaration locks a person out of their own machine. So the mesh's *found-vs-owned* semantics
([ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
([ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
adoption) apply *inside* the home directory. The mesh **owns** the directory and the files above; it
**holds as found — never rewrites, never removes** — the operator's own contents: their **private
keys** and their **personal drop-ins** (`config.d/personal`, the personal `Host` aliases a
workstation carries, exactly as `hosts.local` is the home the mesh never rewrites for `/etc/hosts`).
Reconcile removing an unassigned `config.d/mesh` is fine; the same logic aimed at `id_ed25519` or an
operator's own `authorized_keys` entry is a lockout. This is the login-channel cousin of the rule
[ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
[ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
module draws for the firewall: **the mesh must never be able to arrange the one failure that severs
its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`.
@@ -112,12 +108,12 @@ found-vs-owned boundary of §3 is exactly what guarantees nothing already there
None of this needs a node to discover the mesh, and none of it needs a control-plane module of its
own. The ssh files are **roster facts**
([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
([ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
roster view carries a node's **host key** and its **account** beside its name and address, the
`ssh-client` module ships a template for `known_hosts`, `config` and `authorized_keys`, and the
controller renders each node's copy from the full roster and pushes it. The mesh owns the data; the
module owns ssh's format; the control plane gains no ssh syntax. It is the same act as composing a
peer list or `/etc/hosts` — which is why there is **no control-node-only "mesh-ssh" module**: the
peer list or `/etc/hosts` — which is why there is **no novox-only "mesh-ssh" module**: the
centralization is the controller's composition, not a module that runs somewhere. Only non-secret
facts travel (names, addresses, accounts, host keys, the CA public key); the private key stays the
operator's, placed as an operator-owned file, referenced by path.
@@ -133,44 +129,6 @@ operator's, placed as an operator-owned file, referenced by path.
They meet at the account and the CA, not at a bespoke module. The `sshd` server side already exists;
the client/identity side and the CA are the open pieces.
## What has shipped, and what has not
*Recorded 2026-10-01 from the controller's main branch, not from intent.*
**Built (mesh-controller, merged 2026-09-27):**
- **§1, the account as a node fact.** A node record carries an operator account and, optionally,
its home. Empty is a real state — a freshly enrolled or headless machine has no operator account
known yet — and an empty home means *derive it* (the superuser's home for the superuser, the
conventional per-user home otherwise), so the common case needs no entry. The controller's node
command sets it. One account per node is what exists; "one or several" below is still open.
- **§2, resources under a home.** The account and its home are offered as machine facts, and a
resource's *path and owner* resolve placeholders exactly as its content does — so a module places
a file under a person's home, owned by that person, naming neither. A roster file may say it lives
under the home: it is rendered per node, placed under that node's account's home, chowned to the
account, and a node with no account gets none.
- **§5, the composed ssh config.** The roster rendering carries each node's account, so the
`ssh-client` template can emit a `Host` block per node with the right login name. Composed
end-to-end in the controller's tests.
**Written but not shipped:** the `ssh-client` catalogue module itself exists on a branch of the
module repository; its pull request was closed with a hold until this design is deployed, and
nothing has deployed it since. The predecessor's generator still writes every workstation's ssh
client blocks today — which is where [issue 172](../../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)
was found.
**Not built:** the SSH CA and certificates (§4), `known_hosts` and `authorized_keys` as roster files,
the found-vs-owned boundary inside `~/.ssh` (§3 — the controller has no rule yet that refuses to
rewrite a private key), adoption of existing keys, the ssh-agent as a user service, and user-scoped
services in general. The host vocabulary still has no user-scope unit at all; a workstation's
per-user daemons (a bar watchdog, a config reloader, an audio service masked per user) have no form
the mesh can send.
**A gap this surfaced:** §1 shipped as code before it had a decision record. The account as a node
fact, the home as a placement root, and what the mesh may and may not do under a home are each a
decision this document names but no record states. They are the next records to write, before the
family of §2 modules is built.
## Why now, and why not yet
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
@@ -179,7 +137,7 @@ alias and its trust, and a fresh machine has no operator dotfiles at all — the
service and leave the human unable to work on the box.
**Why not build it reflexively:** it is a real addition to the node model, the resource model, and
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0128 is what lets
the ssh files be templates with no control-plane format — so what remains to decide here is the
model:
@@ -198,18 +156,15 @@ model:
unnecessary, and forwarding an agent into a node exposes the operator's keys to that node's root —
so prefer certificates and `ProxyJump` over forwarding.
**Now load-bearing.** The migration of every node to the mesh is complete; what remains of the
predecessor is exactly the user environment this design covers — ssh config, dotfiles, the desktop
stack and the per-user services of the two workstations. Those generators are the last thing
keeping the predecessor running, so the model questions above are no longer deferred: the account
record, the home as a placement root, user-scoped services and the one-off steps a hook used to run
each need a decision before the modules that replace the generators can be written.
**Not urgent, not blocking.** ssh and dotfiles work today because HAL's generators still run as the
substrate. This becomes load-bearing in the node-by-node retirement phase, not before — which is the
right time to build it, once the account and CA model are decided here.
## References
- The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s
postConfigure hook), which the nox mesh has no equivalent for.
- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
fact mechanism that renders the ssh files, format owned by the module.
- [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) — the
system-path placement this mirrors for home paths.
@@ -218,6 +173,6 @@ each need a decision before the modules that replace the generators can be writt
- [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the
vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md)
— short-lived certs as rotation.
- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
[ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
- [ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
[ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`.
+1 -1
View File
@@ -38,7 +38,7 @@ document is written and this one's status becomes `implemented`.
| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) |
| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) |
| [`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) | **In progress.** A node has an operator account and a resource may live under its home — built in the controller; the ssh-client module, the SSH CA, the `~/.ssh` boundary and user-scoped services are not. The account fact still wants its decision record | [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) |
| [`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), 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) |
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-22
located-in: [mesh-controller internal/overlay, mesh-host internal/apply]
fixed-by: ADR 0102 (mesh-controller internal/overlay: the runtime file written into, reloaded), issue 128 (the hosts file as a region)
fixed-by:
amended-design: 03-DESIGN/01-to-be/05-the-node-host.md
---
@@ -56,12 +56,3 @@ and nothing checks for it today.
- Should an adopted node that cannot trust the registry be refused a module that needs to pull?
Or should the refusal come earlier, when the node joins?
## Resolved, 2026-10-02
The runtime's file is written into and the runtime reloaded, never restarted
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md), the
diagnosis above); the hosts file is a marked region the mesh owns alone
([issue 128](../128-the-hosts-file-is-written-whole/00-report.md)). Neither whole file remains. Read
into [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rule 5,
which names the one whole machine-wide file the mesh still writes — its own filter at the
distribution's path — as a difference a take shows, not a fault.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-22
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-controller 201 (the preview names the narrowing and the port's reach), 206 (`take --yes <digest>` acts on the preview read)
fixed-by:
amended-design:
---
@@ -40,13 +40,3 @@ it changes before it changes it, and for taking a module this one does not.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the preview names a narrowing. Building follows,
host first, then the controller's `take`.
## Built, 2026-10-02
mesh-controller 201 and the pull request after it: the preview names it, and `take --yes <digest>`
acts on the preview that was read. Stays located until a take is read on an adopted machine — every
machine of this mesh is converged today, so the record's live row has not been run.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02 with the built and tested code live on every machine (mesh-controller 206, mesh-host 64), not on a take read on an adopted machine: every machine of this mesh is converged, so none holds a found thing to compare, and the record's live row — ADR 0163's last — will be read at the next real adoption rather than staged. Said here so nobody later believes that row was run.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-22
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-host 64 (genesis raises the forge as `gitea`, on the module's image digest, with the module's data directory at /data; a test holds the installer to the module's manifest)
fixed-by:
amended-design:
---
@@ -53,17 +53,3 @@ network, or it is not a takeover.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 7: genesis raises as the module declares. Building follows,
host first, then the controller's `take`.
## Built in part, 2026-10-02
mesh-host 64: genesis raises the forge under the module's container name (`gitea`), pinned to the
module's image digest, with the module's data directory mounted at `/data` — so the module finds it,
holds it, and a take compares equal images and the same data. A test holds the installer's constants
to the module's manifest where the catalogue is checked out beside it. The network is the difference
left: the bootstrap forge runs on the machine's network to reach the store on its loopback, the module
runs bridged and publishes its ports, and the take says so. Closing waits for group 9's genesis test —
a mesh raised, the module assigned, and the module found holding rather than raising a second forge.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02. Name, image and data directory align; the network does not — the bootstrap forge runs on the machine's network to reach the store on its loopback, the module runs bridged — and a take says so rather than hides it. Whether genesis should move the forge onto a bridge, and the test that raises a mesh and finds the module holding rather than raising a second forge, belong to group 9's genesis work and are not owed by this record any more.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-controller (the pull request after 201: JudgeSettings, LeftOut), mesh-host 64 (left_out kept)
fixed-by:
amended-design:
---
@@ -63,12 +63,3 @@ knowing the code.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 6: judged where stored; an impossible statement costs a module. Building follows,
host first, then the controller's `take`.
## Resolved, 2026-10-02
One judgement, in the catalogue, run where a setting is stored and where a machine is composed. Stored,
a setting that cannot compose with the module's current definition is refused naming the node, the
module, the layer and the key; a key that reaches nothing is refused there too. Composed, a definition
that moved under a stored setting leaves that module out of the machine's declaration — the envelope
names it, the host keeps what it holds and wrote for it, `plan` and `push` say it — and the machine is
told everything else. A stray setting no longer refuses the whole machine where it is read.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-host 63 (former targets removed, strays reported), mesh-controller 201/202 (strays shown)
fixed-by:
amended-design:
---
@@ -80,11 +80,3 @@ found, and so would be kept for ever on purpose.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 5: former targets are removed and strays reported. Building follows,
host first, then the controller's `take`.
## Resolved, 2026-10-02
mesh-host 63: the host's record keeps a resource's former targets, removes a container or file it
wrote under a name the declaration no longer names, never what was found, and reports strays — what
runs on the machine that the mesh neither wrote nor holds. mesh-controller 201 and 202 show strays
on `node show` for an adopted and a converged machine alike; the live mesh reported four on the
control node the evening it rolled.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-host 63 (the kept original's difference), mesh-controller 201 (shown; a differing file refuses unless `--replace <path>`)
fixed-by:
amended-design:
---
@@ -68,13 +68,3 @@ written.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the difference is shown and a differing file refuses. Building follows,
host first, then the controller's `take`.
## Built, 2026-10-02
mesh-host 63 reports the difference between the kept original and the declared content; mesh-controller
201 shows it in the preview and refuses a differing file unless `--replace <path>` names it, or the
module declares the file partially. Stays located until a take is read on an adopted machine.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02 with the built and tested code live on every machine (mesh-controller 206, mesh-host 64), not on a take read on an adopted machine: every machine of this mesh is converged, so none holds a found thing to compare, and the record's live row — ADR 0163's last — will be read at the next real adoption rather than staged. Said here so nobody later believes that row was run.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-host 63 (both images' creation dates), mesh-controller 201 (DOWNGRADE said; refused unless `--downgrade`)
fixed-by:
amended-design:
---
@@ -65,13 +65,3 @@ expected rate.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the images are compared by age and a downgrade refuses. Building follows,
host first, then the controller's `take`.
## Built, 2026-10-02
mesh-host 63 reports the found image and both images' creation dates; mesh-controller 201 says
DOWNGRADE and refuses unless `--downgrade` is said. Stays located until a take is read on an adopted
machine.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02 with the built and tested code live on every machine (mesh-controller 206, mesh-host 64), not on a take read on an adopted machine: every machine of this mesh is converged, so none holds a found thing to compare, and the record's live row — ADR 0163's last — will be read at the next real adoption rather than staged. Said here so nobody later believes that row was run.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-controller 201 (`secret accept --provider` reaches a required secret), 206 (a module's secrets listed with origin; a minted one for found data refuses unless `--mint <name>`)
fixed-by:
amended-design:
---
@@ -70,15 +70,3 @@ the module can only be installed fresh.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 2 and 3: a minted secret for found data refuses; secret accept reaches required secrets. Building follows,
host first, then the controller's `take`.
## Built, 2026-10-02
`secret accept <node> <module> <name> --provider <node>` reaches a required secret (mesh-controller 201).
The pull request after it reads every secret a module holds on a machine with its origin, and a take
of a module whose data was found refuses a minted, unaccepted one — naming the accept that carries
the existing value in, or `--mint <name>` to let the service take the new one. Stays located until
a take is read on an adopted machine.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02 with the built and tested code live on every machine (mesh-controller 206, mesh-host 64), not on a take read on an adopted machine: every machine of this mesh is converged, so none holds a found thing to compare, and the record's live row — ADR 0163's last — will be read at the next real adoption rather than staged. Said here so nobody later believes that row was run.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-09-23
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
fixed-by: mesh-controller 201 (the neighbours on a found network are named), 206 (the per-machine `networks` setting), mesh-host 64 (the taken container joins the kept network)
fixed-by:
amended-design:
---
@@ -66,14 +66,3 @@ exercise.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 4: the neighbours are named; a found network may be kept by a setting. Building follows,
host first, then the controller's `take`.
## Built, 2026-10-02
The preview names every neighbour on a found network (mesh-controller 201). The pull request after it
adds the per-machine setting `networks` — a container id to the found networks it keeps — judged for an
adopted machine only, and mesh-host 64 has the taken container join each once it runs. Stays located
until a take is read on an adopted machine.
## Resolved, 2026-10-02
Closed on the operator's decision of 2026-10-02 with the built and tested code live on every machine (mesh-controller 206, mesh-host 64), not on a take read on an adopted machine: every machine of this mesh is converged, so none holds a found thing to compare, and the record's live row — ADR 0163's last — will be read at the next real adoption rather than staged. Said here so nobody later believes that row was run.
@@ -1,9 +1,7 @@
---
status: resolved
status: located
opened: 2026-09-26
located-in: [mesh-host internal/apply]
fixed-by: mesh-host 63 (every written field compared), mesh-controller 201 (build says the policy)
amended-design:
---
# 126 — a volume path is not in the spec comparison, and a roll-out raced a data move
@@ -52,9 +50,3 @@ the install-page junk was discarded twice.
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 5 and 7: every field compared; build says the policy. Building follows,
host first, then the controller's `take`.
## Resolved, 2026-10-02
mesh-host 63: every field the host writes is compared before a container is called current, volumes
and paths included. mesh-controller 201: `build` and the take-in say when a module's policy rolls a
result out at once; under ADR 0162 the plan says it too.
@@ -1,10 +1,10 @@
---
status: resolved
status: located
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/filtering.go
- mesh-host internal/apply
fixed-by: ADR 0140 — mesh-controller (the filter around outward links; no network ranges anywhere)
fixed-by:
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
@@ -86,11 +86,3 @@ supersedes both 0137 and the first attempt at answering this.
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?
## Resolved, 2026-10-02
By [ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), built and
live since 2026-09-29: the forward chain constrains what arrives on the machine's outward links and
says nothing about networks, so there is no list to derive and nothing for a preview to tell apart.
Read into [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md),
rule 5, which closes it.
@@ -100,11 +100,3 @@ harmless, but the mesh's belief about which firewall is in force has been wrong
so". Should it?
- Why do the host's own detail lines not reach the journal? Everything it decided during the flip is
unrecoverable, which is why this account has candidates instead of a cause.
## Decided, 2026-10-02
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rule 1:
convergence is a state the host keeps — the found firewall active again is retired again and said, a
reconcile that finds it inactive records *found so* and never *done by the mesh*, and a step skipped
after a failed apply is said. Built in mesh-host on `feat/one-thing-filters-a-converged-machine`; the
record of both machines of this mesh is corrected by the first report under it.
@@ -82,12 +82,3 @@ everything reached from within.
- 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.
## Decided, 2026-10-02
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rules 2 and
3: the host reports every table and legacy chain that refuses, with an owner, and the runtime's user
chain's refusals as *other*; `node show`, `status` and the converge preview say it. Built on
`feat/one-thing-filters-a-converged-machine` in mesh-host and mesh-controller. On 2026-10-02 the home
server still carries the predecessor's chain in its legacy filter; the record's live row is reading it
there.
@@ -1,8 +1,8 @@
---
status: resolved
status: located
opened: 2026-10-01
located-in: [mesh-controller examples/route-proxy/main.go (routesFrom requires a route's public `name` and treats `internal-name` only as an alias of it; the handler serves every routed name to any source), mesh-controller internal/broker/membership.go (a membership says nothing of what its module receives or who the mesh is)]
fixed-by: mesh-controller PR 207 (the membership carries what a module receives and who the mesh is; the proxy follows it and serves internal names to the mesh only), mesh-catalog PR 211 (the proxy's bus account), mesh-controller PR 208 (the issue verb that delivers it), live 2026-10-02
fixed-by:
amended-design: [03-DESIGN/01-to-be/08-connectivity.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
---
@@ -59,22 +59,3 @@ It also publishes an administration interface to the internet to get a name on t
only in its own log? The same silent skip covers a route with no usable port or an unknown scheme.
- What checks that what the controller composes and what the proxy serves stay the same shape? ADR
0138 changed one side and nothing failed on the other.
## Resolved (2026-10-02)
Built as [ADR 0167](../../02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)
decided, and live on both machines that run the proxy. Each logs that its routes now come from its
membership, and serves internal names to the four machines the mesh names. Checked by hand:
- the internal-only route answers through the proxy from the serving machine and from two other
machines of the mesh, over a certificate from the mesh's own authority that each verifies;
- the same name asked from an address outside the mesh is answered as a name never routed, over plain
HTTP, and refused in the TLS handshake; the list of served names it is shown leaves out every internal
name.
Two things the rollout found are their own records: the proxy's bus account could be issued only from
the controller's command line, until mesh-controller PR 208 added the `issue` verb, and the status line
counting every module as a bus user without a credential is
[issue 195](../195-every-assigned-module-is-counted-as-a-bus-user-without-a-credential/00-report.md).
The serving machine also lacked the certificate-trust module, so it could not verify the mesh's own
certificates until it was assigned there.
@@ -1,71 +0,0 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-host internal/apply (removeOrphan: a former target of a kind with no removal was fatal), mesh-host internal/store (Record keeps a former target for every kind, the host's own archive included)]
fixed-by: mesh-host 65 — a former target of a kind the host cannot remove is left in place, said and forgotten; a dropped archive still refuses
amended-design:
---
# 194 — The host's own former archive stops every machine applying anything
## What was observed
2026-10-02, 00:34Z, on all four machines of this mesh, the first time a host carrying former
targets ([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 5,
built in mesh-host 63) replaced itself with a newer host (mesh-host 64).
The host delivers its own successor as an archive whose target is a versioned directory
([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md)): every new version
is the same resource with a new target. Since mesh-host 63 the record keeps a resource's former
target so the next apply removes what the host wrote under it
([issue 097](../097-a-resource-that-changes-target-leaves-the-old-one-behind/00-report.md)). So
the new host's first apply found the previous version's directory as a former target of its own
archive, and asked the removal for an archive — which does not exist
([issue 162](../162-an-archive-cannot-be-undeclared/00-report.md)):
```
applying "mesh-host.next@former:/usr/lib/nox-mesh-host/versions/3c906749ad27": no way to remove a "archive"
0 resource(s) were applied and remain
```
Orphans are removed before any resource is applied on a converged machine, so the refusal ended
every apply at its first step. Every machine reported `failed`, applied nothing, and would have
gone on doing so: a host fix is itself an archive the same apply would have to write, and the apply
never reached it. The machines kept running what they had; nothing new from the mesh could land.
## Why it matters beyond this instance
Two rules that are each right met in the one resource the host cannot afford to stop on. Rule 5
says a former target is removed and said; issue 162 says an archive has no removal, deliberately,
so an unassignment nothing can undo is never reported as done. Neither rule was wrong; their
meeting was never tested, because the bed that would have found it is a host replacing itself
under the new rule, and the first such replacement was the live one. The fix is narrow: a former
target of a kind the host cannot remove is left in place, said, and forgotten — never fatal,
because nobody dropped it. An archive the declaration dropped still refuses, as 162 has it.
## What it took to recover
The broken host cannot apply its own fix: the fix is delivered as an archive, and the apply fails
before writing anything. On each machine the host's record (`/var/lib/mesh-host/state.json`) had to
lose the one `@former:` entry by hand, once, so that the next push could write the fixed archive and
stand aside for it. A manual edit of the host's record is otherwise never done; it is written here
because the alternative was four machines that could apply nothing.
## Open questions
- Should `Record` keep a former target for a kind the host cannot remove at all? The trace is
useful; the removal it implies is not. Keeping it and letting the apply forget it is what the fix
does; not recording it would be quieter.
- Should the host's own versions directory be cleaned by the launcher rather than by the apply —
the one archive whose former targets are genuinely removable, by the thing that knows which one
runs?
- Is there a bed that replaces a host under the current rules before the live mesh does
(the proof row of [ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md) was
a single crossover, before former targets existed)?
## Resolved, 2026-10-02
mesh-host 65, merged 07:35Z. Recovered as the record above says: the operator dropped the one
`@former:` entry from each machine's host record and pushed; the fixed host then ran on all four and
its first apply said `forgotten mesh-host.next@former:… a former target left in place` and applied the
rest. The open questions stand as questions for the host's own versions, not as faults.
@@ -1,62 +0,0 @@
---
status: open
opened: 2026-10-02
located-in: []
fixed-by:
amended-design:
---
# 195 — Every assigned module is counted as a bus user without a credential, and the real gaps are lost in the count
## What was observed
`status`, and `plan` for any machine, open with one line before anything else:
```
the bus's user list leaves out 49 user(s) the mesh has minted no credential for: <node>.<module>, …
Each is a user that cannot connect until one is issued
```
The 49 are spread over four machines and name 26 distinct modules. Checked against the catalogue on
2026-10-02:
| what the module's definition says | modules |
|---|---|
| declares an own secret named `broker` | 1 — the route proxy, which needed a bus account for issue 191 |
| declares no `broker` secret, and emits, consumes and serves nothing on the bus | 17 — the packet filter, the intrusion filter, the ssh daemon, the resolver configuration, the certificate authority, the broker itself and others |
| declares no `broker` secret, and **emits events** | 1 |
| not in this catalogue, so not checked | 7 |
So the line counts every module assigned anywhere as a bus user. For almost all of them that is not a
missing credential. A module with no `broker` secret has nowhere to receive one, and the mesh already
says an account nothing reads is an orphan ([issue 078](../078-a-delivered-secret-is-accepted-under-any-name/00-report.md)).
Two real gaps sit inside the count and cannot be told from the noise:
- **A declared `broker` secret was filled with a value that is not an account.** Before its account was
issued, the route proxy's plan on both machines already carried a sealed `broker` file, while the same
status line said no credential had been minted for it. A push had made the declared secret the way it
makes any own secret. The module would have started with a credential the bus does not know, and
nothing would have said why. It was found only because the account was being issued by hand.
- **A module that emits events declares no way to reach the bus.** Its events can go nowhere, and no
check refuses that.
## Why it matters
**A warning that is always on is read as never on.** The line names 49 users on every `status` and every
`plan`. An operator, or an agent, learns to scroll past it. The one entry that was a real fault looked
exactly like the 48 that were not.
**The fault that was real is the silent kind.** A module whose broker credential is a generated value
starts, fails to authenticate, and reports that three layers away from the cause. That is the failure
the composition already refuses for a secret that was never made at all ("declared and not made"). Here
a value was made, so the refusal never fired.
## Open questions
- Should a bus user be composed for a module that declares no `broker` secret at all? If not, the line
shrinks to the modules that can actually use an account.
- Is a `broker` secret ever correctly made by the generic generator? If not, should composition refuse
a declared `broker` until it is issued, or should the mesh issue it as part of placing the module?
- Should a module that emits, consumes or serves on the bus be refused when it declares no `broker`
secret?
@@ -1,54 +0,0 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-controller internal/catalogue/filtering.go (AsNftables: the forward chain has no rule for the mesh passing through, so a relayed packet is judged by this machine's own published ports)]
fixed-by: mesh-controller PR 209 (the forward chain relays what comes in and goes out on the tunnel), live 2026-10-02
amended-design: []
---
# 196 — The hub relays the mesh only on the ports it publishes for itself
## What was observed
A sweep of every listening port on every machine, from every other machine, on 2026-10-02. Two home
machines, neither of which can be dialled, reach a third home machine through the hub, as
[ADR 0007](../../02-DECISIONS/0007-connectivity.md) says every path between machines that are not
co-located does.
From either of the two, the third answered on **17 of its 55** listening ports over the mesh. The hub
itself, probing the same machine directly, reached all the ports that machine's rules open to the mesh.
The result was the same at 40 probes in parallel and at 4, so it was not load.
The 17 were not a property of the target. They were exactly the ports **the hub** publishes for its own
containers: ssh, the proxy's two, and the hub's own block of published ports. A capture on the target
during one probe to a port that answered and one that did not:
- the answering one: the SYN arrives on the tunnel, reaches the container, and the reply leaves by the
tunnel;
- the other: nothing arrives at all, on any interface.
## Why it matters
**ADR 0007's hub carries every path between machines that are not co-located, and the filter breaks
that path without saying so.** Whether one home machine can reach a service on another depends on
whether the hub happens to publish the same port number for something of its own. Adding or removing
a module on the hub silently opens or closes paths between two other machines that it has nothing to
do with.
It also hid behind another fault. A missing placement made the same pair look disconnected earlier the
same day, and that explanation fit well enough that the per-port pattern was not looked for.
## Open questions
- The relaying rule accepts what comes in on the tunnel and leaves on it, and leaves judging to the
machine it is for. Should the hub also restrict relayed traffic to what that machine opens to the
mesh? That would duplicate the target's rules on the hub.
- No test raises two machines behind a hub and checks a port between them that the hub does not
publish. The lab's beds have one machine per site.
## Resolved (2026-10-02)
Live on all four machines after one push each. The same sweep, from both home machines to the third
over the mesh: 45 of 55 ports answer, the same 45 the hub reaches directly. The 9 that do not are
ports the target opens to nobody on the mesh, and one is refused because it listens only on a LAN
address. Nothing answers that the target's rules do not open.
@@ -1,27 +0,0 @@
# Diagnosis
*2026-10-02.*
**Not the tunnel.** The route from either home machine to the target is the tunnel, and traffic to the
17 ports travels it in both directions. A placement fault would have stopped every port.
**Not the target's filter.** The target opens the failing ports to every address of the mesh in its
input chain and its forward chain, the hub reaches them directly, and the SYN for a failing port never
arrived at the target to be judged.
**The hub's forward chain.** A relayed packet comes in on the tunnel and leaves on it, so the hub's
forward hook judges it. The chain the controller renders (`AsNftables`) has a default of drop, accepts
established traffic, and accepts what did not arrive on an outward link or the tunnel. That last rule is
for the machine's own containers reaching outward. After that come the rules for this machine's own
published ports, each matching the **original destination port** of the connection. None of them names
an outgoing interface or a destination. So a relayed packet to another machine's port 20000 matched the
hub's own rule for its own port 20000 and passed. One to port 8080, which the hub does not publish,
matched nothing and was dropped.
**The fix.** One rule: in on the tunnel **and** out on the tunnel is accepted. That is the mesh passing
through to another of its machines, which filters it against its own rules. It does not widen anything
on the hub. A packet for the hub itself is the input chain's, and one for the hub's own containers
leaves by a bridge, not the tunnel. Both still meet their rules. WireGuard only accepts a packet from a
peer whose address that peer is allowed to use, so in-on-the-tunnel means from a machine of the mesh.
A controller test asserts the rule in the forward chain only, never in the input chain, and absent on a
machine with no tunnel. It fails without the fix, and the rendered set loads with `nft -c`.