Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e27116cd0 | ||
|
|
78351560f7 | ||
|
|
62cc2f89c7 | ||
|
|
426f741ad0 | ||
|
|
9bed54d3be | ||
|
|
413daf8ad5 | ||
|
|
5c993c09b7 | ||
|
|
7c3be48db2 | ||
|
|
b665d06701 | ||
|
|
1bd13446d4 | ||
|
|
14adaafa53 | ||
|
|
bd673cc6ec | ||
|
|
bd6c55d225 | ||
|
|
2bbbc56502 | ||
|
|
c8935aceca | ||
|
|
29b656f2c0 | ||
|
|
d05ac367f1 | ||
|
|
1c0dafb918 | ||
|
|
018ee359ae | ||
|
|
c5535eeebd | ||
|
|
dbb9d2bc16 | ||
|
|
28d53dcc28 | ||
|
|
df667eb710 | ||
|
|
098a2ca485 | ||
|
|
a34cedeb5d | ||
|
|
db5ff5a5ee | ||
|
|
c4151e6bc4 | ||
|
|
8c231102f8 |
@@ -9,6 +9,11 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
|
||||
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
||||
just a machine that has joined; being one implies nothing about what it runs.
|
||||
- **operator account** — the login name of the person who works on a node, stated on the node
|
||||
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
|
||||
resolved against this account's home and owned by it
|
||||
([ADR 0169](../02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
|
||||
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
|
||||
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
||||
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
||||
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
||||
|
||||
@@ -124,6 +124,32 @@ This corrects a fact, not the decision: one statement per endpoint, three things
|
||||
none of them deciding on its own, all stand. The table in the decision should be read with the filter
|
||||
column applying to an unrouted endpoint.
|
||||
|
||||
## Progressive insight — 2026-10-02, from issue 191
|
||||
|
||||
**For a routed endpoint, "the proxy serves the internal name" has to mean "serves it to the private
|
||||
network", and only the proxy can make it mean that.** The decision says `internal` means the proxy
|
||||
serves the internal name and not the public one. It does not say to whom, and the proxy answered
|
||||
every name it routes to any request that carried it, on the same listeners as its public names. A
|
||||
name being internal kept nobody out: a request from the internet only had to send it. While every
|
||||
routed endpoint also had a public name, nothing showed it. Once an endpoint could be internal alone
|
||||
([issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)), serving
|
||||
its name to everyone would have published exactly what `internal` was chosen to keep private.
|
||||
|
||||
The earlier insight above says the port is not the path for a routed endpoint. This is its other
|
||||
half: the proxy is the path, so the proxy is where `internal` is enforced. It serves an internal name
|
||||
only to the machines of the mesh and to the machine itself
|
||||
([ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md)). Who the mesh is, it is told,
|
||||
not left to work out: its membership carries the same list of machine addresses the filter's "from the
|
||||
mesh" is rendered from ([ADR 0167](0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)).
|
||||
To anyone else, the name is answered as one never routed, in the handshake and in the request, and not
|
||||
listed among the names it serves. This holds for the internal name of a `both` endpoint too, whose
|
||||
outsiders have its public name.
|
||||
|
||||
The decision, the options and the consequences stand: one statement per endpoint, three things
|
||||
derived from it. Checked in the proxy's own tests: an internal-only name is served to a machine the
|
||||
membership names and to loopback, and refused, unlisted and uncertified for any other request; until
|
||||
the mesh is issued, it is served to the machine alone.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.**
|
||||
|
||||
@@ -131,6 +131,32 @@ 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)
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||
---
|
||||
|
||||
# 167. A membership carries what its module receives, and who the mesh is
|
||||
|
||||
## Context
|
||||
|
||||
A provider learns what it is given from a file. The controller composes every consumer's contribution
|
||||
to a requirement, and the node's declaration writes them into the provider's received file. The route
|
||||
proxy reads its routes that way: one JSON file, re-read every two seconds.
|
||||
|
||||
[Issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md) showed what
|
||||
that file leaves out. Since [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md),
|
||||
a route whose endpoint reaches only the private network carries an internal name and no public one.
|
||||
The proxy dropped it. Serving it was not enough either: the proxy answers public and internal names on
|
||||
the same listeners, so an internal name served to every request is public under a guessable name. To
|
||||
serve it correctly the proxy needs a second fact, **who the mesh is**, and nothing gave it one.
|
||||
|
||||
The first attempt had the proxy work it out: the mesh's range from an environment variable written by
|
||||
the catalogue, and the machine's container bridges read from its own interfaces. That is a second
|
||||
definition of "the mesh", kept by one module, beside the one the packet filter already uses. The
|
||||
controller resolves "from the mesh" to every machine's address on the private network, and the filter
|
||||
is rendered from that list. Two definitions agree until one changes.
|
||||
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
already gives every assignment one document on the bus, its membership, read once at connect and
|
||||
followed. It says what the assignment serves and reaches. It does not yet say what it is given.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the file, add the mesh to it.** The proxy keeps polling a file, and the controller writes the
|
||||
mesh's addresses beside the routes. It fixes the definition, but delivery stays a file re-read on a
|
||||
timer, written by a separate path from the one every other fact a module is told now takes.
|
||||
2. **Have the proxy work it out** from a range in its environment and the machine's interfaces. Rejected:
|
||||
it is the second definition this record exists to remove.
|
||||
3. **The membership carries it.** What each module receives, from the same composition its received
|
||||
file is written from, and the mesh's addresses, from the same list the filter is rendered from. The
|
||||
proxy follows its membership and serves exactly that.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.**
|
||||
|
||||
- **A membership carries what its module receives**, by requirement: the contributions every consumer
|
||||
made, exactly as composed for its received file. A requirement nobody contributed to is an empty
|
||||
list, never absent, for the reason the file is written empty: "nothing asked" and "never told" want
|
||||
different responses.
|
||||
- **A membership carries who the mesh is**: every machine's address on the private network, the list
|
||||
a rule saying "from the mesh" resolves to. One list, two readers: the filter and any module that
|
||||
must tell the mesh from the world.
|
||||
- **The route proxy reads its routes and the mesh from its membership**, with the bus account every
|
||||
module that speaks on the bus is given. It serves an internal name only to the machines the mesh
|
||||
names and to the machine itself, and answers anyone else as it answers a name it never routed: in
|
||||
the request, in the handshake, and in the list of names it serves.
|
||||
- **The file stays until the bus has spoken.** While a proxy has read no membership that carries routes,
|
||||
it serves the file, and an internal name only to its own machine: refused, never opened. A
|
||||
membership from a controller that issues no routes changes nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every membership grows two fields. A machine joining or leaving republishes every membership, which
|
||||
a push already does.
|
||||
- A provider that receives something is told it twice for now, in its file and on the bus. The file
|
||||
goes when every provider reads its membership; that is its own change.
|
||||
- The route proxy needs a bus account. It is issued like any module's, so a machine running the proxy
|
||||
cannot be composed between the catalogue declaring the account and the operator issuing it. The
|
||||
machine keeps what it runs meanwhile.
|
||||
- The internal name of a route that also has a public one is now served to the mesh only. Outsiders
|
||||
have the public name.
|
||||
- A container on the same machine that calls that machine's own internal name arrives from its
|
||||
container network, not from a mesh address, and is refused. Calls between machines are unaffected:
|
||||
they leave by the machine's mesh address. Whether the mesh should also issue each machine's container
|
||||
networks is left open, because the mesh does not record them today.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| What a provider receives on the bus is what its received file says, same-node port fix included | a controller test composing a provider and a consumer on one machine and comparing the two |
|
||||
| An internal name is served to the machines the membership names and to loopback, and to nobody else | the proxy's tests: served from a named address and from loopback; refused, unlisted and uncertified from any other |
|
||||
| A membership that carries no routes, or a mesh that cannot be read, changes nothing | the proxy's tests |
|
||||
| Until the mesh is issued, an internal name is served to the machine alone | the proxy's tests |
|
||||
| Live: an internal-only route answers over the mesh and is refused from outside | by hand, after the release |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md) —
|
||||
the membership this extends
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — reach, and the
|
||||
insight of 2026-10-02 that the proxy is where internal reach is kept
|
||||
- [ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md) — the machine itself is always inside
|
||||
- [Issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md) — what
|
||||
found it
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-27
|
||||
deciders: jochen
|
||||
reconstructed: true
|
||||
extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
---
|
||||
|
||||
# 169. The operator account is a node fact, and a home is a placement root
|
||||
|
||||
*Reconstructed. The controller shipped this on 2026-09-27 and
|
||||
[to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) recorded it as built without a
|
||||
decision behind it. This record states what was decided, from the code and the design, and adds the
|
||||
two rules the code left implicit — what an empty account means for a module, and that the account is
|
||||
stated rather than discovered. Written 2026-10-02.*
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) took every host path out of a
|
||||
module definition and gave a module's *system* data a place: a directory the mesh resolves under the
|
||||
node's root, owned by the module. It said nothing about the other half of a filesystem — the files
|
||||
that belong under a person's home and are owned by that person. The predecessor wrote several of
|
||||
those: the ssh client configuration, the shell's configuration, an agent's instruction files. It knew
|
||||
whose home it was writing into because each of its node records carried a login name. The mesh took
|
||||
the machine facts over and dropped the human one.
|
||||
|
||||
The loss was found the ordinary way: `ssh <node>` logged into the home-server under the workstation's
|
||||
own login name, because nothing in the mesh said the home-server's account was a different one
|
||||
([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md),
|
||||
[issue 172](../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)).
|
||||
|
||||
What the controller does since 2026-09-27: a node record carries an operator account and, optionally,
|
||||
its home; the account and its home are machine facts a definition may name in a resource's path, owner
|
||||
and content; a roster file may say it lives under the home, and is then rendered per node, placed under
|
||||
that node's account's home, owned by the account, and left out on a node with no account. On
|
||||
2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has
|
||||
stated it, so no home-scoped resource can land anywhere yet.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The definition names the login.** `owner: <name>` in the module. Rejected: it is the installation
|
||||
written into a definition, which ADR 0112 forbids and
|
||||
[ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) checks for, and it is
|
||||
wrong on the first machine whose login differs — which is exactly the machine that surfaced this.
|
||||
2. **The host discovers the account.** The first non-system user, or whoever ran the enrolment.
|
||||
Rejected: a guess. A shared machine has several people on it, a server may have none, and a host
|
||||
deciding whose files these are is a decision the mesh then cannot see, state or correct.
|
||||
3. **The account is a fact the operator states on the node record, and the home is derived from it
|
||||
unless stated.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A node has an operator account: the login name of the person who works on it.** It is stated by the
|
||||
operator on the node record, the way a node's address or mode is held there, and it is empty for a
|
||||
machine nobody logs into. Empty is a real state, not a missing value. The mesh holds the fact because
|
||||
everything below derives from it, and because it is precisely the fact that was lost when the
|
||||
predecessor's records were not carried over.
|
||||
|
||||
**The account's home is derived unless stated.** The superuser's home for the superuser, the
|
||||
distribution's conventional per-user home otherwise; a node whose account lives elsewhere states its
|
||||
home. One place computes the default, so a fact and the record cannot disagree about it.
|
||||
|
||||
**A resource may be placed under the home, owned by the account.** This is ADR 0112's move one level
|
||||
over: as a module's system directory is resolved under the node's root, a file under a person's home is
|
||||
resolved against the account's home, and owned by the account rather than by root or a module's own
|
||||
account. A definition names the account and its home as machine facts, never as a path; a roster fact
|
||||
may say it is a home file and is then placed and owned the same way. The controller resolves both at
|
||||
composition, and the host chowns what it creates.
|
||||
|
||||
**A node with no account cannot carry a home-scoped resource, and says so.** A roster fact that lives
|
||||
under the home is left out of that node's declaration rather than written to nowhere. A resource naming
|
||||
the account fact on such a node is refused at composition, naming the fact the machine does not have.
|
||||
A module that writes a person's files is thereby unassignable to a machine with no person on it, which
|
||||
is the right refusal.
|
||||
|
||||
**One account per node is what this record decides.** Several people on one machine is left open, with
|
||||
the constraint that allowing it must not force the common case — one workstation, one person — to name
|
||||
anything.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the
|
||||
first assignment of such a module begins with four node records.
|
||||
- The roster carries each node's account, so a composed ssh configuration logs in as the right person
|
||||
on every machine — the gap that surfaced this, closed by the same fact.
|
||||
- A family of modules becomes writable: everything the predecessor placed under a home — ssh client,
|
||||
shell, the agent's instruction files — is now a module naming a fact rather than a path
|
||||
([to-be 29 §2](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)).
|
||||
- **What got harder:** a definition cannot say "my user's home" without the mesh knowing who the user
|
||||
is, so a module of this family is refused on a freshly enrolled machine until a person is named on
|
||||
it. That is a prompt, not an obstacle.
|
||||
- **Not decided here:** several accounts per node; a service unit running as the account rather than
|
||||
as root or a module; a one-off step run as the account. Each is a record of its own.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A resource's path and owner resolve the account and its home | controller tests on machine-fact resolution: a file naming the account facts lands under the account's home, owned by the account |
|
||||
| The home is derived unless stated | a controller test: the superuser's home for the superuser, the conventional home otherwise, the stated home when one is stored |
|
||||
| A home roster fact is left out on a node with no account | a controller test on roster composition: the file is absent from that node's declaration and present on a node with an account |
|
||||
| A resource naming the account on a node with no account is refused by name | a controller test on machine-fact resolution: the refusal names `account` and lists the facts the machine does have |
|
||||
| No definition names a home path | ADR 0112's catalogue test on host paths, which a `/home` or `/root` literal fails |
|
||||
|
||||
## References
|
||||
|
||||
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the design this record
|
||||
gives a foundation to, and its "what has shipped" section
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement
|
||||
this mirrors; [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) — why
|
||||
a login name may not be in a definition
|
||||
- [ADR 0120](0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact a home file
|
||||
may be
|
||||
- [ADR 0170](0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) — what
|
||||
the mesh may and may not do inside the home this record lets it reach
|
||||
- mesh-controller `internal/inventory/nodes.go` (the account and its home on the node record),
|
||||
`internal/catalogue/machine_into_files.go` and `roster.go` (resolution and the home fact)
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md
|
||||
---
|
||||
|
||||
# 170. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0169](0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
|
||||
place files under a person's home. A home is unlike any directory the mesh has written into so far:
|
||||
it is shared with the person, and with every program the person runs. The agent's configuration
|
||||
directory on the laptop makes the point. On 2026-10-02 it holds thirty entries. The predecessor placed
|
||||
five of them (an instruction file, a conventions rule, a settings file it merged into, two skills); a
|
||||
sibling module placed a sixth (the node's identity rule). The agent itself writes the other
|
||||
twenty-four: its settings, its credentials, its history, the memory of every project it has worked in,
|
||||
its plugins, its session logs. Several of those are what [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md)
|
||||
calls memory *written by the session itself and declared by nobody*: a mechanism that regenerated the
|
||||
directory would erase a season of it, silently, while reporting success.
|
||||
|
||||
The predecessor's own module recorded the hazard in the other direction. Its settings file was first
|
||||
shipped as *replace*, and every `/model` choice a person made inside a session was reverted to the
|
||||
template's value on the next synchronisation — on every node, indefinitely, with no indication why. It
|
||||
was changed to *merge*, and the comment explaining why is still in its manifest.
|
||||
|
||||
**And the generator is gone while its output stayed.** The predecessor was retired from the laptop on
|
||||
2026-10-01. Its six files are still on both workstations, with their content telling every session to
|
||||
use tools that no longer exist. Nothing owns them; nothing will ever rewrite or remove them.
|
||||
|
||||
[To-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) drew the line for one directory,
|
||||
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
|
||||
personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is
|
||||
the same shape with a different stake — the person's work rather than the person's way in — and it has
|
||||
to hold for every directory the family of home-scoped modules will touch, so it is a rule, not a
|
||||
section.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The module owns the directory whole**, regenerating it from the definition. Rejected: it destroys
|
||||
the memory, history and local settings the agent writes for itself, which is the failure to-be 15
|
||||
names and the predecessor's settings file demonstrated at small scale.
|
||||
2. **The module owns only the files it names, and nothing about the directory.** Rejected: *owning one
|
||||
file beside foreign ones is not owning anything* (to-be 29). The directory must exist, with the right
|
||||
owner and mode, before the tool first runs on a fresh machine; and a credentials file in a
|
||||
world-readable directory is a credentials file in the wrong directory.
|
||||
3. **The module owns the directory and the files it places; a file the tool writes for itself is
|
||||
written into, never over; everything else is held as found.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates
|
||||
it if absent, owned by the account, and never removes it while it holds anything
|
||||
([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)). Inside it, every path the module touches
|
||||
is in exactly one of four classes, and **the class is visible in the definition from the shape
|
||||
declared**, not inferred from what happened to be on disk:
|
||||
|
||||
| class | declared as | the host's rule |
|
||||
|---|---|---|
|
||||
| **owned** | a file with content, or a roster fact | written whole, regenerated, removed when undeclared; a file found there with no record of the mesh making it is kept once before it is written over ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)) |
|
||||
| **written into** | a file written *into* a structured document | only the keys the definition names are set, every other key is kept, and each set key is given back when undeclared (ADR 0102). The key list is the module's and is short |
|
||||
| **written by the module's own process** | a secret the mesh delivers to the module, and a step that writes the file from it | the file's content is never a declared file's content, because a declaration travels in the clear on the bus and the host records it; the module's process writes it, owned by the account, atomically. [ADR 0171](0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md) says how for a credential |
|
||||
| **found** | nothing | never read, never rewritten, never removed. The person's memory, history, projects, local settings, their own rules and skills |
|
||||
|
||||
**A file the tool writes for itself is written into, never over.** The agent's settings file and its
|
||||
own state file are the tool's; the mesh has one or two facts to state in each. Setting those keys and
|
||||
nothing else is what lets a person's `/model` choice survive a push, and what lets the mesh's keys be
|
||||
taken back cleanly when the module goes.
|
||||
|
||||
**A predecessor's output is found.** A file placed by a generator that no longer exists is, to the
|
||||
mesh, a file it has no record of making. Where the successor module keeps the path, declaring it
|
||||
*adopts* it: the host keeps the original once and writes the mesh's. Where the successor does not keep
|
||||
the path, the mesh does not remove the file, because it removes nothing it did not make; **the operator
|
||||
removes it, once**, and the module's definition names those paths in its own documentation so the step
|
||||
is not forgotten. This is the first instance of the one-off setup step to-be 29 leaves open, and the
|
||||
rule chosen for it is that it is a person's act, listed, not a module's.
|
||||
|
||||
**The rule is the family's.** An ssh client module, a shell module, an agent module each declare their
|
||||
directory and classify their paths this way. A module that cannot say which class a path is in has not
|
||||
finished its definition.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A person's work under their home survives every push and every unassign. The mesh's own files come
|
||||
and go with the module; the mesh's keys in the tool's files come and go with it; the directory stays.
|
||||
- **Stale files survive too.** Two workstations keep three predecessor files each until a person removes
|
||||
them — a visible cost, accepted over a mesh that deletes under a person's home. A module author who
|
||||
renames one of the mesh's own files has the ordinary path: the old resource id is undeclared and the
|
||||
host removes what it made ([ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)).
|
||||
- A module's definition is longer by a classification, and a reviewer has one more question per path.
|
||||
That is the point: *which parts are managed must be explicit rather than inferred* (to-be 15).
|
||||
- **What got harder:** a module cannot seed a person's preference once and leave it. A seeded file
|
||||
([ADR 0087](0087-a-seeded-file-is-created-once.md)) is the shape for that, and it is available to
|
||||
this family unchanged; what is refused is a seed the module later wants to change, because what grew
|
||||
in it is the person's.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The directory is created owned by the account and kept when the module goes | host tests of a directory resource with an owner (ADR 0051's and 0118's), and the family's lab check below |
|
||||
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
|
||||
| Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared |
|
||||
| Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands |
|
||||
| Every path a home-scoped module touches is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) |
|
||||
|
||||
## References
|
||||
|
||||
- [to-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the same boundary drawn for `~/.ssh`
|
||||
- [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md) — a session's memory is declared by nobody
|
||||
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0087](0087-a-seeded-file-is-created-once.md),
|
||||
[ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md) — the mechanics each class rests on
|
||||
- [ADR 0051](0051-shared-data-is-the-operators.md) — the third case the host had no word for: what it neither made nor configured
|
||||
- the predecessor's `claude-code` module manifest, whose comment on `strategy: merge` records the reverted `/model` choice
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||
---
|
||||
|
||||
# 171. The mesh binds and delivers a licence; the module alone writes the tool's credential; a switch is the binding changed, asked for through the console
|
||||
|
||||
## Context
|
||||
|
||||
**The operator's stance, set on 2026-10-02:** the controller has no part in the agent module. The
|
||||
module owns the agent's directory under the operator's home and every related file, handles them
|
||||
itself, and carries a licence-switching function as the predecessor's did.
|
||||
|
||||
**What the predecessor's switching actually was.** A registry of accounts held server-side; a tool,
|
||||
callable from a session, that decrypted the chosen account's token on the server, refreshed it if near
|
||||
expiry, and wrote the agent's credentials file on the target node — never returning the token. Beside
|
||||
it, a shell helper that ran the agent with a token read from a plaintext file in the operator's own
|
||||
configuration directory, one token per account, on every workstation; and an enrolment helper that
|
||||
logged in once in a throwaway home and registered what came out. So the central half did the
|
||||
refreshing and the writing; the node held nothing it could refresh with; and the convenience path kept
|
||||
every account's token readable on disk wherever it was wanted.
|
||||
|
||||
**What the mesh has.** [To-be 14](../03-DESIGN/01-to-be/14-model-access.md) is built as far as it goes:
|
||||
a licence is a named record with a vendor; a consumer is a module on a node and is put on one licence;
|
||||
the access token is sealed per holder and delivered to the holder's machine; for a refreshable grant
|
||||
the manager node alone holds the refresh token, encrypted, and refreshes centrally — the one stated
|
||||
carve-out of [ADR 0050](0050-model-access-is-vendor-agnostic.md). The controller has commands to add a
|
||||
licence, put a consumer on it, release it, accept a key, set a manager, set and refresh a grant. **None
|
||||
of them is a verb on the `mesh-controller` seat**, so none can be asked for through the console
|
||||
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md) exposes eighteen commands and
|
||||
not these). The catalogue has the manager module and a consumer module that already writes an
|
||||
access-token-only credentials file at a path it is told; both are assigned to nothing.
|
||||
|
||||
**Why refresh is central and must stay so.** A refreshable grant rotates its refresh token on use. Two
|
||||
machines each refreshing one account's grant race: the second refresh presents a token the first
|
||||
retired. The predecessor refreshed centrally for this reason, and ADR 0024 kept that half on purpose
|
||||
(*the hard half of this already — and it works*). ADR 0050 narrowed the consequence to one node.
|
||||
|
||||
**The two designs are not in conflict, and the line has to be drawn in a record.** The controller
|
||||
resolving *whose* home a file lands in ([ADR 0169](0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md))
|
||||
and *which* licence a consumer holds (to-be 14) is what the controller does for every module. "No
|
||||
part" cannot mean that, or the module could not be assigned. It can mean — and this record says it
|
||||
means — that **the controller learns nothing about the agent**: no file shape, no path, no key, no
|
||||
word beyond the vendor adapter it already has.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The module keeps its own registry of accounts and tokens**, the stance read literally. Rejected: a
|
||||
second secret store outside the vault ([ADR 0113](0113-the-vault-makes-every-secret.md)); a refresh
|
||||
token on every workstation, widening ADR 0050's one-node carve-out to every machine a person sits
|
||||
at; and two records of one licence, which drift.
|
||||
2. **The agent refreshes itself**: the mesh delivers a full grant once at a switch and the agent's own
|
||||
refresh keeps it alive. Rejected: the refresh race above, between the agent and the manager and
|
||||
between two machines on one account; and every node then holds a refresh token, which ADR 0050
|
||||
decided no node does.
|
||||
3. **The mesh binds and delivers; the module writes; a switch is the binding changed, asked for through
|
||||
the console.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The consumer is the module on the machine: the operator's interactive sessions on that node, under
|
||||
that account, hold one licence at a time.** That is to-be 15's `(node, module)` identity, with the
|
||||
agent module as the module. Two machines may hold different licences, the ordinary case. The mesh's own
|
||||
sessions on a machine are other modules and hold theirs in their own right.
|
||||
|
||||
**The mesh delivers; the module writes.** The module requires `model-access`. The mesh resolves the
|
||||
licence the consumer is on, delivers the access token sealed to the machine as a secret in the module's
|
||||
own state, and delivers the non-secret facts — the licence's name, what it serves — beside it. **The
|
||||
module's own process writes the agent's credentials file** from the delivered secret: under the
|
||||
account's home, owned by the account, readable by nobody else, written atomically, and access-token-only
|
||||
— a refresh token found there is removed, because a node never holds one (ADR 0050). The file's content
|
||||
is never a declared file's content ([ADR 0170](0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
|
||||
The step runs when the delivered secret changes and on a schedule as a backstop, so a refreshed token
|
||||
reaches the file without anyone asking.
|
||||
|
||||
**The controller learns nothing about the agent.** Where the file is, what shape it has, what the
|
||||
agent calls its keys, how it is told about the console — all of that is the module's definition and
|
||||
code. The controller contributes the facts it contributes to every module: the account, the home, the
|
||||
licence, the delivery.
|
||||
|
||||
**A switch is the binding changed.** Putting the consumer on another licence is the mesh's existing
|
||||
act — *use this licence, for this consumer* — and it becomes a verb on the `mesh-controller` seat the
|
||||
way the other verbs did (ADR 0154): the command it already has, served on the bus, listed by the
|
||||
console. The module serves two tools of its own: one that reports which licence the machine holds and
|
||||
when its token expires, and one that invokes the seat's verb for a named licence and then waits until
|
||||
the credentials file carries the new licence's token, answering with the licence's name — **never the
|
||||
token, in any answer, log or event**. A skill in the agent's directory wraps the second so a person
|
||||
asks in a sentence. Switching remains a reaction, not a declaration (ADR 0024): a person asks for it,
|
||||
and nothing in the declaration language grows a conditional.
|
||||
|
||||
**Enrolling an account is the mesh's act on the manager node.** A new licence is added by name, its
|
||||
grant obtained by a login in a throwaway home on the manager node and adopted sealed to that node's
|
||||
key, as the manager module already does. No token is pasted into a prompt, printed, or passed as an
|
||||
argument (to-be 14's rule for keys).
|
||||
|
||||
**The shell helper that read tokens from a file is retired, not replaced.** A second concurrent
|
||||
session on the same machine under a different licence would need a second consumer identity — the
|
||||
unbuilt half of to-be 14's gap — and is not provided here. Stated so it is not rediscovered as a bug.
|
||||
|
||||
**A licence the mesh no longer grants is withdrawn** at the binding (ADR 0024). The credentials file
|
||||
the module wrote is the module's own output: unassigning the module leaves it, like the agent's other
|
||||
files, and the access token in it expires within hours. Releasing the consumer from the licence is the
|
||||
act that ends its access.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The agent on a workstation authenticates with a token the mesh delivered and refreshes centrally,
|
||||
and no workstation holds a refresh token or any other account's token.
|
||||
- **The manager must run.** The refresh path exists in the catalogue and is assigned to nothing; it is
|
||||
a prerequisite of this record, on the control node, and the first thing the build proves.
|
||||
- **The controller gains a verb, not knowledge.** The licence commands become seat verbs, each
|
||||
running the command it names, as ADR 0154 did for the others; nothing in them is about the agent.
|
||||
- A switch is a round trip — binding, composition, push, apply — rather than the predecessor's direct
|
||||
write: seconds to a minute, and reported when done rather than assumed.
|
||||
- **What got harder:** running two sessions on one machine under two accounts at once, which the
|
||||
retired helper allowed by keeping tokens readable. The price of not keeping them so.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The module's definition declares no secret in a file's content, and requires `model-access` | a catalogue test on the module's definition |
|
||||
| The credentials file is access-token-only, owned by the account, atomic | the consumer module's existing unit tests on the strip and the write, carried into this module; a live check that the file names no refresh token |
|
||||
| A switch through the console changes the licence and the token, and no answer carries a token | a live check: the bound facts name the new licence, the file's fingerprint changes, the tool's answer and the module's log contain neither token |
|
||||
| The controller's licence verbs run the commands they name and carry no agent vocabulary | the seat verb's test, as for the eighteen before it |
|
||||
| The refresh path is live before the module is | the manager assigned on the control node and a refresh observed in the licence's record, before the module's first assignment |
|
||||
| No workstation holds a refresh token | the live check above, on every machine the module is assigned to |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md),
|
||||
[ADR 0055](0055-model-access-is-answered-by-a-licence-or-a-node.md) — what a licence is, who refreshes, what answers
|
||||
- [to-be 14](../03-DESIGN/01-to-be/14-model-access.md), [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md) — the consumer identity and the gap this leaves where it is
|
||||
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — how a verb reaches a person
|
||||
- [ADR 0113](0113-the-vault-makes-every-secret.md) — why there is no second registry
|
||||
- [ADR 0170](0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) — the class the credentials file is in
|
||||
- the predecessor's `claude-code` module: its switch tool, its shell helpers and the rules of its skill
|
||||
- mesh-catalog `modules/anthropic-manager`, `modules/anthropic-consumer` — the refresh and the write, as built
|
||||
@@ -177,6 +177,8 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0161** — [What deserves a seat: a role of a module is a seat, a singular fact about machines is a placement with a capacity of one, and a holder's software is the machine's](0161-what-deserves-a-seat.md)
|
||||
- **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
|
||||
|
||||
@@ -267,6 +269,9 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0150** — [A module's own code runs as supervised processes under the module's one account](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)
|
||||
- **0152** — [The operator's surface is a module the mesh assigns: the console](0152-the-operators-surface-is-a-module-the-console.md)
|
||||
- **0155** — [A definition names no installation: how that is checked, and the three ways a value that did gets out](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
|
||||
- **0169** — [The operator account is a node fact, and a home is a placement root](0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
|
||||
- **0170** — [Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found](0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||
- **0171** — [The mesh binds and delivers a licence; the module alone writes the tool's credential; a switch is the binding changed, asked for through the console](0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md)
|
||||
|
||||
### How it is built
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-host]
|
||||
updated: 2026-10-01
|
||||
updated: 2026-10-02
|
||||
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
|
||||
@@ -163,6 +164,27 @@ 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
|
||||
|
||||
@@ -7,8 +7,10 @@ code:
|
||||
- mesh-controller internal/identity/authority.go
|
||||
- mesh-host internal/identity/serving.go
|
||||
- mesh-host internal/apply (the service that reflects a rule set)
|
||||
updated: 2026-09-30
|
||||
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
|
||||
- 02-DECISIONS/0147-a-module-anchors-the-meshs-authority.md
|
||||
@@ -709,6 +711,38 @@ 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.**
|
||||
@@ -842,6 +876,18 @@ One value, three readers:
|
||||
| `public` | the machine port, to anywhere | the public name | the public authority |
|
||||
| `both` | the machine port, to anywhere | both names | each name's own authority |
|
||||
|
||||
*2026-10-02.* **The proxy serves an internal name to the private network only**
|
||||
([ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md),
|
||||
its insight of this date). It answers public and internal names on the same listeners, so the name a
|
||||
request carries is the request's own claim, not where the request came from. An internal name is
|
||||
served to the machines of the mesh and to the machine itself; to anyone else it is answered as a name
|
||||
never routed, in the handshake as well as the request. Without this, an endpoint with reach `internal`
|
||||
would be public under a name that is easy to guess
|
||||
([issue 191](../../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)).
|
||||
The proxy is told who the mesh is, and its routes, in its membership on the bus — the same machine
|
||||
addresses the filter's "from the mesh" is rendered from
|
||||
([ADR 0167](../../02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)).
|
||||
|
||||
**An endpoint that is not routed is reached and never named.** No route contribution means no name is
|
||||
composed and no certificate requested, while the filter still acts on it. That is the case the model
|
||||
could not express at all, and it is the ordinary case for anything that is not HTTP.
|
||||
|
||||
@@ -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-01
|
||||
updated: 2026-10-02
|
||||
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,6 +323,21 @@ 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
|
||||
|
||||
@@ -4,9 +4,10 @@ status: in-progress
|
||||
code:
|
||||
- mesh-controller internal/licences
|
||||
- mesh-controller cmd/mesh-controller/licence.go
|
||||
updated: 2026-09-05
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||
- 02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md
|
||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
||||
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
|
||||
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
|
||||
@@ -96,6 +97,13 @@ So `(node, module)` tells them apart, and asking for a licence per session neede
|
||||
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
|
||||
given its own key, and releasing one leaves the other.
|
||||
|
||||
*2026-10-02:* the operator's own interactive agent on a workstation is a consumer the same way —
|
||||
`(node, claude-code)`, one licence at a time per machine, delivered by the mesh and written by the
|
||||
module, switched through the console
|
||||
([ADR 0171](../../02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md),
|
||||
[36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md)). The licence commands
|
||||
become verbs on the controller's seat for it; none was one before.
|
||||
|
||||
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
|
||||
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
|
||||
and this reasoning does not extend to them. That belongs with
|
||||
|
||||
@@ -7,8 +7,9 @@ code:
|
||||
- mesh-tools src/broker-amqp.ts (to be replaced)
|
||||
- mesh-catalog modules/nats (to be written)
|
||||
- mesh-sdk src (the protocol's NATS binding, step 3)
|
||||
updated: 2026-10-01
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
|
||||
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
|
||||
- 02-DECISIONS/0106-the-bus-is-nats.md
|
||||
@@ -94,6 +95,12 @@ list; the account's grant is the same membership read the other way; the console
|
||||
tool's subject. The one rule a runtime keeps is the membership's own subject, from the two names in its
|
||||
credential.
|
||||
|
||||
**Revised 2026-10-02** ([ADR 0167](../../02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)):
|
||||
a membership also carries **what its module receives**, by requirement — the contributions its
|
||||
received file is written from, from the same composition — and **who the mesh is**, every machine's
|
||||
address on the private network, the list the filter's "from the mesh" is rendered from. The route
|
||||
proxy is the first reader of both.
|
||||
|
||||
**Revised 2026-09-27** ([ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md)):
|
||||
**`mesh.build.request`, `mesh.control.built` and the BUILDS stream are gone.** A build is work submitted to a role, and the
|
||||
mesh already has a shape for that — a seat's `accept` subjects, on a work queue with a queue group of
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: proposed
|
||||
code: []
|
||||
updated: 2026-09-27
|
||||
status: in-progress
|
||||
code:
|
||||
- mesh-controller internal/inventory
|
||||
- mesh-controller internal/catalogue
|
||||
- mesh-controller cmd/mesh-controller
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
|
||||
- 02-DECISIONS/0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 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
|
||||
- 02-DECISIONS/0051-shared-data-is-the-operators.md
|
||||
@@ -14,10 +19,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: `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
|
||||
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
|
||||
person's `~/.ssh/config`, `~/.zshrc`, `~/.config`); the mesh, taking those over, kept the machine
|
||||
facts and dropped the human one.
|
||||
|
||||
@@ -28,8 +33,9 @@ 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 ace` failed to `ace` because nothing
|
||||
in the mesh said ace's account is `ace`.
|
||||
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.
|
||||
|
||||
## 2. A resource may live under a home, owned by its account
|
||||
|
||||
@@ -65,14 +71,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 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
|
||||
([ADR 0118](../../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 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
|
||||
[ADR 0117](../../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`.
|
||||
|
||||
@@ -108,12 +114,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 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
|
||||
([ADR 0120](../../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 novox-only "mesh-ssh" module**: the
|
||||
peer list or `/etc/hosts` — which is why there is **no control-node-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.
|
||||
@@ -129,6 +135,52 @@ 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.
|
||||
|
||||
*2026-10-02:* two of them are written. [ADR 0169](../../02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
|
||||
records the account as a node fact and the home as a placement root, reconstructed from what shipped;
|
||||
[ADR 0170](../../02-DECISIONS/0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||
generalises §3's boundary to every directory under a home. The first member of the §2 family is
|
||||
designed in [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md). Still
|
||||
unwritten: user-scope units, several accounts per node, and the CA. On the same day every node of the
|
||||
live mesh still carried an empty account.
|
||||
|
||||
## Why now, and why not yet
|
||||
|
||||
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
|
||||
@@ -137,7 +189,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 0128 is what lets
|
||||
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets
|
||||
the ssh files be templates with no control-plane format — so what remains to decide here is the
|
||||
model:
|
||||
|
||||
@@ -156,15 +208,18 @@ 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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## 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 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
|
||||
- [ADR 0120](../../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.
|
||||
@@ -173,6 +228,6 @@ right time to build it, once the account and CA model are decided here.
|
||||
- [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 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) —
|
||||
- [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) —
|
||||
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`.
|
||||
|
||||
@@ -28,6 +28,14 @@ module's credential and listens on loopback. It has no state, no provision, no s
|
||||
the bus, which it gets the way every module does: a credential the mesh minted for `<node>.mesh-console`,
|
||||
sealed to the machine, delivered as the module's own secret.
|
||||
|
||||
*2026-10-02:* it gains one provision, at node scope — the MCP endpoint on loopback, serving the port
|
||||
the machine gave it — so that a module whose software must be told where the console is requires that
|
||||
and is coupled to an endpoint rather than to a module's name
|
||||
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). The first
|
||||
consumer is the operator's agent, [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md) §6;
|
||||
a machine without the console refuses such a module by name. Nothing else above changes: no seat, no
|
||||
state, no tools of its own.
|
||||
|
||||
Its manifest says three things nothing else in the catalogue says together:
|
||||
|
||||
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
|
||||
- 02-DECISIONS/0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md
|
||||
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
|
||||
- 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md
|
||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
- 02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md
|
||||
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
||||
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
|
||||
- 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md
|
||||
---
|
||||
|
||||
# 36 — The operator's agent on a machine: the `claude-code` module
|
||||
|
||||
**The agent a person runs at a terminal, put on the machine by the mesh, instructed by the mesh,
|
||||
pointed at the console, and authenticated with a licence the mesh delivers.** It is the first member of
|
||||
the family [to-be 29 §2](29-a-node-has-operator-accounts.md) names — the modules that place files under
|
||||
an operator's home — and the smallest, so it is where the pattern is proven before the shell, the
|
||||
terminal and the desktop follow.
|
||||
|
||||
What it replaces: the predecessor's module of the same name, which installed the agent's package and
|
||||
placed five files under the operator's home, and a sibling that placed a sixth. The predecessor is
|
||||
retired; those six files are still on both workstations telling every session to use tools that no
|
||||
longer exist. That is the symptom this design answers, and it answers it by making the files a module's
|
||||
again rather than by editing them.
|
||||
|
||||
## 1. What it is
|
||||
|
||||
A module, `claude-code`, universal tier: assigned to every node a person logs into, which is every node
|
||||
with an operator account ([ADR 0169](../../02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
|
||||
It declares the agent's package, owns the agent's configuration directory under the account's home, and
|
||||
requires two things: `model-access`, for the licence
|
||||
([ADR 0171](../../02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md)),
|
||||
and the console on the same machine, for the tools
|
||||
([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)). It names no
|
||||
node, no path and no login: the account and its home are machine facts, the node's name is a machine
|
||||
fact, the node's role is a setting on the assignment, and the console's address is what the console
|
||||
serves.
|
||||
|
||||
**The controller has no part in it beyond what it has in every module.** It resolves the account, the
|
||||
home, the licence and the console's port, and delivers them. It holds nothing about the agent: no file
|
||||
shape, no key name, no path. The one controller change this design asks for is not about the agent at
|
||||
all — the licence commands become verbs on the controller's seat, as the other commands did
|
||||
([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)).
|
||||
|
||||
## 2. What it owns under the home, and what it leaves alone
|
||||
|
||||
Every path the module touches is in one of the four classes
|
||||
[ADR 0170](../../02-DECISIONS/0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||
draws, and the class is visible from the shape the definition declares. The agent's directory is
|
||||
`~/.claude`; its own state file is `~/.claude.json` beside it.
|
||||
|
||||
| path | class | declared as |
|
||||
|---|---|---|
|
||||
| `~/.claude/` | owned directory | a directory, owner the account, readable by the account alone |
|
||||
| `~/.claude/CLAUDE.md` | owned | a file: how a session on this mesh works (§4) |
|
||||
| `~/.claude/rules/00-mesh.md` | owned | a file: this node's identity (§4) |
|
||||
| `~/.claude/rules/conventions.md` | owned | a file: the rules of the repositories (§4) |
|
||||
| `~/.claude/skills/mesh-licence/SKILL.md` | owned | a file: the licence skill (§5) |
|
||||
| `~/.claude/settings.json` | written into | the agent's settings; the mesh's key is `attribution`, and only that (below) |
|
||||
| `~/.claude.json` | written into | the agent's own state; the mesh's key is the console's entry under the servers the agent speaks to (§3) |
|
||||
| `~/.claude/.credentials.json` | written by the module's process | a delivered secret and a step (§5) |
|
||||
| everything else | found | nothing — the person's memory, history, projects, local settings, plugins, their own rules and skills |
|
||||
|
||||
**Which keys of the settings file are the mesh's.** A key is the mesh's when it encodes a rule of the
|
||||
mesh, and the person's when it is a preference. `attribution` — the trailers the agent adds to commits
|
||||
and pull requests — encodes the repositories' convention and is the mesh's. The model, the spinner, the
|
||||
drafts, the automation mode and everything else are the person's, and the predecessor's experience with
|
||||
the model key is the evidence: a mesh that sets a preference reverts a person's choice on every push. A
|
||||
preference the operator wants on every machine belongs to the family's dotfiles module, not here.
|
||||
|
||||
**The agent's own state file is written into for one key.** The agent is told about the console as one
|
||||
entry among the servers it speaks to, in the file where it keeps that list. Everything else in that
|
||||
file — the account it is logged in as, its caches, its history of projects — is the agent's, and
|
||||
ADR 0102's rule is exactly what keeps it: the mesh sets one key and gives it back on undeclare.
|
||||
|
||||
## 3. The predecessor's six files
|
||||
|
||||
They were placed by a generator that no longer exists; to the mesh they are found. ADR 0170 says what
|
||||
happens to each kind, and this is the list:
|
||||
|
||||
| file | fate |
|
||||
|---|---|
|
||||
| `CLAUDE.md`, `rules/conventions.md` | **adopted.** The module declares the same paths; the host keeps the found original once and writes the mesh's content ([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)) |
|
||||
| `settings.json` | **written into.** The values the predecessor merged and the person changed since — the model among them — stay; the mesh sets its one key |
|
||||
| `rules/00-hal-mesh.md` | **removed by the operator, once.** Its successor is `rules/00-mesh.md`; the old name carries the predecessor's and stays otherwise |
|
||||
| `skills/hal-switch-license/SKILL.md` | **removed by the operator, once.** Its successor is `skills/mesh-licence/SKILL.md` |
|
||||
| `skills/cleanup/SKILL.md` | **removed by the operator, once.** A repository hygiene skill naming the predecessor's forge and repository; not the mesh's |
|
||||
|
||||
The module's documentation names the three removals, so a person assigning it on a workstation that
|
||||
carried the predecessor knows the step. On a fresh machine there is nothing to remove.
|
||||
|
||||
**The console's entry changes name.** The agent on both workstations today reaches the console under
|
||||
an entry named after this installation. A definition names no installation
|
||||
([ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md)), so
|
||||
the module writes the entry as `mesh`, and every tool an agent sees is prefixed accordingly. The
|
||||
hand-made entry is the person's to remove; until they do, the agent sees the mesh's tools twice.
|
||||
|
||||
## 4. What the three documents say
|
||||
|
||||
**Prose, not a paste** — the files are the module's; this is what they are for.
|
||||
|
||||
**`CLAUDE.md` — how a session on this mesh works.** The console is the only path to the mesh, and its
|
||||
tools are the vocabulary: the record is asked through the records module's tools, symptom first — the
|
||||
literal error text before a hypothesis — and that is the *search before you dig* rule rewritten for a
|
||||
knowledge base that is now the record itself ([ADR 0153](../../02-DECISIONS/0153-the-record-is-read-by-a-module-and-the-console-lists-it.md));
|
||||
the mesh is asked and changed through the controller seat's verbs — status, plan, assign, push,
|
||||
settings, and licence once it exists; the forge through the forge module's tools. The hard rules are the
|
||||
same rules in new words: a file the mesh manages is changed through the verb that owns it or through
|
||||
the catalogue, never on disk, and `plan` says what the mesh would write; a store's database is never
|
||||
written by hand; main is never pushed; the mesh creates no symlinks and nobody else does either; a
|
||||
package is declared, not installed by hand. It uses the glossary's words — controller, foundation,
|
||||
node, seat, console — and none of the predecessor's.
|
||||
|
||||
**`rules/00-mesh.md` — who this node is.** Two facts and one pointer: the node's name, from the
|
||||
machine; the node's role, from the assignment's settings on this node; and that the other nodes are
|
||||
asked of the controller's `nodes` verb rather than listed here. The predecessor's rule carried a table
|
||||
of every node with its public domain and role; a table is a copy that drifts, and the live answer is
|
||||
one tool call away. No address, no public domain.
|
||||
|
||||
**`rules/conventions.md` — the rules of the repositories.** Concise commit messages in the imperative,
|
||||
focused on why; a branch, a pull request and a human approval for every merge; test before pushing,
|
||||
because nodes update unattended; follow the playbooks in the record; shared logic in the SDK; the
|
||||
module repository's rules on manifests. Nothing that names a tool of the predecessor's.
|
||||
|
||||
**Where the module gets the name and the role.** The name is a machine fact the controller already
|
||||
offers a definition. The role is a value a person chooses per node — *the laptop*, *the home-server* —
|
||||
and is an operator value on the assignment's node layer, refused by name when unset
|
||||
([ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md)).
|
||||
So assigning the module to a node is two acts: the assignment, and the node's role in its settings.
|
||||
|
||||
## 5. The licence
|
||||
|
||||
[ADR 0171](../../02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md)
|
||||
decides it; this is the shape.
|
||||
|
||||
**The consumer** is `(node, claude-code)`: the operator's interactive sessions on that machine, under
|
||||
that account, on one licence at a time. The module requires `model-access` and is put on a licence like
|
||||
the consumer module already in the catalogue.
|
||||
|
||||
**Delivery and the write.** The mesh delivers the access token sealed to the machine, as a secret in
|
||||
the module's own state directory, and the bound facts beside it. A step in the module's own process —
|
||||
the consumer module's existing write, carried over — reads the secret and writes
|
||||
`~/.claude/.credentials.json`: owned by the account, readable by the account alone, atomically,
|
||||
access-token-only. The step names the secret file as what it reads and runs again when it changes
|
||||
([ADR 0099](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)), and on a schedule as
|
||||
a backstop, so a refreshed token reaches the file unasked. It runs in the module's own context and
|
||||
never as the person.
|
||||
|
||||
**Refresh** is the manager module's on the control node, as [ADR 0050](../../02-DECISIONS/0050-model-access-is-vendor-agnostic.md)
|
||||
built it. It is assigned to nothing today and is the first prerequisite of the build.
|
||||
|
||||
**The two tools** the module serves, listed by the console under the module's name:
|
||||
|
||||
| tool | answers |
|
||||
|---|---|
|
||||
| `licence_status` | which licence this machine's agent holds, from the bound facts; when its access token expires; whether the file on disk matches what was delivered — by fingerprint, never by value |
|
||||
| `licence_switch` | asks the controller seat's `licence` verb to put this consumer on the named licence, waits until the credentials file carries the new licence's token, and answers with the licence's name and expiry. Refuses with the mesh's own words when the licence does not exist or the consumer cannot be put on it |
|
||||
|
||||
Neither tool, nor the module's log, nor any event it emits, ever carries a token. The module declares
|
||||
that it invokes the controller seat's `licence` verb, and nothing else.
|
||||
|
||||
**The skill** — `skills/mesh-licence/SKILL.md` — wraps `licence_switch` so a person asks in a sentence,
|
||||
and carries the predecessor's rules unchanged in substance: never ask for or print a token; never edit
|
||||
the credentials file by hand; the tool writes the file and the record together; with no licence named,
|
||||
ask rather than guess.
|
||||
|
||||
**Enrolling an account** happens on the manager node: a licence added by name, its grant obtained by a
|
||||
login in a throwaway home and adopted sealed to that node's key, as the manager module does. **The
|
||||
shell helper** that ran the agent with a token from a plaintext file is retired and not replaced
|
||||
([ADR 0171](../../02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md)
|
||||
says why).
|
||||
|
||||
## 6. The console
|
||||
|
||||
The agent reaches the mesh through the console on the machine's loopback
|
||||
([to-be 34](34-the-console.md)). The module must tell the agent the console's address, and the port is
|
||||
the console's to say: today the console's manifest declares it and the host assigns it, and nothing but
|
||||
the console knows what was assigned. So **the console provides a node-scoped provision** — the MCP
|
||||
endpoint on loopback — serving its port, and the module requires it. A requirement names what the
|
||||
consumer is coupled to ([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)):
|
||||
the agent is coupled to an MCP endpoint on its own machine, not to a module name. Co-location resolves
|
||||
it, and a machine without the console refuses the agent module by name — which is right, because an
|
||||
agent without the console is the predecessor's situation again.
|
||||
|
||||
This is a change to the console's definition, not to the controller. [To-be 34 §1](34-the-console.md)
|
||||
says the console has *no provision*; this is the one it gains, at node scope, and the design is amended
|
||||
in the same change.
|
||||
|
||||
## 7. Scope, settings and the order of assignment
|
||||
|
||||
**Every node with an operator account.** None has one today; the operator states them first. A node
|
||||
with no account refuses the module, naming the fact.
|
||||
|
||||
**Per node:** the role, in the module's settings on the node layer. **Per mesh:** nothing.
|
||||
|
||||
**Order:** the manager on the control node and a refresh observed; the licences the operator uses,
|
||||
enrolled; the console's provision and the module in the catalogue; one workstation assigned, the three
|
||||
predecessor files removed there, and a new session read to confirm it sees the mesh's instructions and
|
||||
the console's tools; then the rest.
|
||||
|
||||
## 8. The package
|
||||
|
||||
The module declares the agent's package. The distribution every node of the live mesh runs does not
|
||||
carry it in its repositories: the two workstations have it from a build the predecessor's helper made
|
||||
from the community repository, and nothing updates it since the predecessor retired. On those two the
|
||||
declaration is satisfied — the package is present. **On a fresh machine the host's package manager
|
||||
refuses it, in its own words, and the module is not applied there.** That is correct and is a gap.
|
||||
|
||||
The answer the mesh already has a shape for is a package repository for this ecosystem as a seat
|
||||
([ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md)), fed by the
|
||||
builder with a package it builds from the vendor's release, and trusted by every node's package manager.
|
||||
Then `package: claude-code` is answered the way every package is, and updates arrive the way every
|
||||
update does. It is not built, and it is not this module's to build: it is a seat and a provider module
|
||||
of its own, needed by every package the distribution does not carry.
|
||||
|
||||
Rejected as the answer: the vendor's own installer, which puts a self-updating binary under the
|
||||
person's home. It is a hand-installed package the mesh cannot see, reproduce or roll back, and it
|
||||
updates itself outside the mesh — the arrangement the manifest rule *never install a package by hand*
|
||||
exists to end.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Check | Defends |
|
||||
|---|---|
|
||||
| the module's definition names no node, path or login, and declares no secret in a file's content | ADR 0112, ADR 0155, ADR 0171 |
|
||||
| on a lab machine with an account, a seeded home holding a person's rule file, the predecessor's three leftovers and a settings file with the person's model: after assign, the mesh's files are present and owned by the account, the person's file and model are byte-identical, the leftovers are untouched, the console's entry is set; after unassign, the mesh's files are gone, the two keys are given back, the directory and everything else stand | ADR 0170 |
|
||||
| on a lab machine with no account, the assignment is refused naming the fact | ADR 0169 |
|
||||
| the credentials file is owned by the account, readable by it alone, and names no refresh token; a switch through the console changes the licence named in the bound facts and the file's fingerprint; neither the tool's answer nor the module's log holds a token | ADR 0171, ADR 0050 |
|
||||
| the console's provision resolves by co-location and a machine without the console refuses the module by name | ADR 0027, ADR 0152 |
|
||||
| a new session on the assigned workstation lists the console's tools under the `mesh` prefix and answers "which node am I" from the identity rule | the exit of the build |
|
||||
| the package is reported present on the workstations and refused in the package manager's words on a machine without it | §8, honestly |
|
||||
|
||||
## What this does not settle
|
||||
|
||||
- **Several operator accounts on one node.** ADR 0169 decides one; the module follows.
|
||||
- **A parallel session under another licence on the same machine.** The retired helper allowed it by
|
||||
keeping tokens readable; a clean form needs a second consumer identity (to-be 14's open half).
|
||||
- **The package repository seat.** §8 names it and leaves it to its own design.
|
||||
- **The rest of the family** — shell, terminal, desktop, user-scoped services — each a module of the
|
||||
same shape, each proving nothing new about ownership and something new about its own tool.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0169](../../02-DECISIONS/0169-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md),
|
||||
[ADR 0170](../../02-DECISIONS/0170-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md),
|
||||
[ADR 0171](../../02-DECISIONS/0171-the-mesh-binds-and-delivers-a-licence-and-the-module-writes-the-tools-credential.md) — the three decisions this rests on
|
||||
- [to-be 29](29-a-node-has-operator-accounts.md) — the family; [to-be 14](14-model-access.md),
|
||||
[to-be 15](15-the-agent-session.md) — the licence and the consumer; [to-be 34](34-the-console.md) — the console
|
||||
- the predecessor's `claude-code` module and its sibling's identity rule — what is replaced, read from the workstations on 2026-10-02
|
||||
- mesh-catalog `modules/anthropic-consumer` — the write this module carries over; `modules/anthropic-manager` — the refresh it depends on
|
||||
@@ -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) | **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) |
|
||||
| [`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) |
|
||||
|
||||
| [`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) |
|
||||
|
||||
|
||||
+11
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-22
|
||||
located-in: [mesh-controller internal/overlay, mesh-host internal/apply]
|
||||
fixed-by:
|
||||
fixed-by: ADR 0102 (mesh-controller internal/overlay: the runtime file written into, reloaded), issue 128 (the hosts file as a region)
|
||||
amended-design: 03-DESIGN/01-to-be/05-the-node-host.md
|
||||
---
|
||||
|
||||
@@ -56,3 +56,12 @@ 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: located
|
||||
status: resolved
|
||||
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:
|
||||
fixed-by: mesh-controller 201 (the preview names the narrowing and the port's reach), 206 (`take --yes <digest>` acts on the preview read)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -40,3 +40,13 @@ 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.
|
||||
|
||||
+16
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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:
|
||||
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)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -53,3 +53,17 @@ 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: located
|
||||
status: resolved
|
||||
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:
|
||||
fixed-by: mesh-controller (the pull request after 201: JudgeSettings, LeftOut), mesh-host 64 (left_out kept)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -63,3 +63,12 @@ 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.
|
||||
|
||||
+10
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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:
|
||||
fixed-by: mesh-host 63 (former targets removed, strays reported), mesh-controller 201/202 (strays shown)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -80,3 +80,11 @@ 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.
|
||||
|
||||
+12
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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:
|
||||
fixed-by: mesh-host 63 (the kept original's difference), mesh-controller 201 (shown; a differing file refuses unless `--replace <path>`)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -68,3 +68,13 @@ 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: located
|
||||
status: resolved
|
||||
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:
|
||||
fixed-by: mesh-host 63 (both images' creation dates), mesh-controller 201 (DOWNGRADE said; refused unless `--downgrade`)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -65,3 +65,13 @@ 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.
|
||||
|
||||
+14
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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:
|
||||
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>`)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -70,3 +70,15 @@ 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.
|
||||
|
||||
+13
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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:
|
||||
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)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -66,3 +66,14 @@ 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,7 +1,9 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
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
|
||||
@@ -50,3 +52,9 @@ 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: located
|
||||
status: resolved
|
||||
opened: 2026-09-28
|
||||
located-in:
|
||||
- mesh-controller internal/catalogue/filtering.go
|
||||
- mesh-host internal/apply
|
||||
fixed-by:
|
||||
fixed-by: ADR 0140 — mesh-controller (the filter around outward links; no network ranges anywhere)
|
||||
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
|
||||
---
|
||||
|
||||
@@ -86,3 +86,11 @@ 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.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-29
|
||||
located-in:
|
||||
- mesh-host internal/apply/opening.go (retireFirewall)
|
||||
- mesh-host internal/apply/apply.go (the condition it is called under)
|
||||
fixed-by:
|
||||
fixed-by: mesh-host 67 (retire on every converged apply; found-inactive apart from disabled-by-mesh; a skipped step said), mesh-controller 211 (the found firewall's state on node show)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -100,3 +100,21 @@ 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.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
mesh-host 67 and mesh-controller 211, live on every machine at 10:10Z. The step now runs on every
|
||||
converged apply and says what it did; a found firewall enabled again is retired again. The record's
|
||||
one inherited lie stands as history: on the control node the machine's own record already said the
|
||||
mesh had disabled the firewall, and the host trusts its record, so `node show` says "retired by the
|
||||
mesh" there. From this build on, a reconcile that finds the firewall inactive records *found inactive*
|
||||
and never the other thing. Whether the flip's step took on 2026-09-29 is not recoverable and is not
|
||||
owed by this record any more.
|
||||
|
||||
+23
-2
@@ -1,10 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-29
|
||||
located-in:
|
||||
- mesh-host internal/apply/opening.go
|
||||
- mesh-controller cmd/mesh-controller (the converge preview)
|
||||
fixed-by:
|
||||
fixed-by: mesh-host 67 (every refusing table and legacy chain classified with an owner; the runtime's user chain is other), mesh-controller 211 (kept, shown on node show, named by status, previewed with fates)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -82,3 +82,24 @@ 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.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
mesh-host 67 and mesh-controller 211, live at 10:10Z. The live row of
|
||||
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) was read the
|
||||
same hour: the home server's record names the predecessor's chain in the legacy filter's user chain
|
||||
as *other*, with what it refuses, beside two chains a retired front end left in the IPv6 legacy filter;
|
||||
the control node's record names the same two leftovers; the laptop and the workstation read *the mesh
|
||||
alone*. `status` names both machines and is not well until the operator removes what the mesh did not
|
||||
write. The allowance the predecessor's chain carried is
|
||||
[issue 145](../145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)'s,
|
||||
and that record is not closed by this one.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
status: resolved
|
||||
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
|
||||
amended-design: [03-DESIGN/01-to-be/08-connectivity.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
|
||||
---
|
||||
|
||||
# 191 — A route with only an internal name is dropped as naming nothing
|
||||
|
||||
## What was observed
|
||||
|
||||
A module whose endpoint reaches only the private network could not be reached by its internal name.
|
||||
The module ran and answered on its own port. Its route's internal name resolved to the serving node.
|
||||
The request failed during the TLS handshake:
|
||||
|
||||
```
|
||||
http: TLS handshake error from …: no public route for "unifi.home-server.internal" in this mesh,
|
||||
so no certificate is asked for
|
||||
```
|
||||
|
||||
The proxy's own log said why, every time it re-read its routes:
|
||||
|
||||
```
|
||||
unifi on home-server asked for a route and named nothing; skipped
|
||||
```
|
||||
|
||||
The route it skipped was not empty. The mesh had given it an endpoint, a port, a scheme and an internal
|
||||
name, and no public name:
|
||||
|
||||
| route | `name` | `internal-name` | served |
|
||||
|---|---|---|---|
|
||||
| home-assistant | a public name | `home-assistant.home-server.internal` | under both |
|
||||
| unifi | — | `unifi.home-server.internal` | under neither |
|
||||
|
||||
Three other modules on the same node were skipped with the same line on the same pass.
|
||||
|
||||
## Why it matters
|
||||
|
||||
**Reach is decided in one place, and the proxy reads the old shape of the decision.**
|
||||
[ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)
|
||||
made an endpoint's reach decide which names exist. The controller composes the public name, the
|
||||
internal name, or both, and composes no name that nobody asked for
|
||||
([issue 140](../140-an-endpoints-reach-is-not-declared/01-resolution.md)). An endpoint that reaches
|
||||
only the private network is the ordinary case for anything that should not face the internet. It is
|
||||
exactly the case the proxy drops.
|
||||
|
||||
**The failure is quiet and points the wrong way.** Nothing marks the module unhealthy. The handshake
|
||||
error says *no public route*, which reads as a certificate fault on the reader's side. The line that
|
||||
gives the real cause is one of four identical lines repeated every few seconds in a log nobody reads
|
||||
until they already suspect the proxy.
|
||||
|
||||
**The opposite move is not a workaround.** Giving the endpoint public reach makes the proxy serve it.
|
||||
It also publishes an administration interface to the internet to get a name on the private network.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the proxy refuse a route it cannot serve in a way the controller or an operator sees, not
|
||||
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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Diagnosis
|
||||
|
||||
*2026-10-01.*
|
||||
|
||||
**Ruled out first: the module itself.** Its container was up and had not restarted. The controller's
|
||||
status endpoint answered on its own port with `"up": true`. The tool wrapper beside it was serving
|
||||
all its tools.
|
||||
|
||||
**Ruled out: name resolution.** The internal name resolved to the serving node's private-network
|
||||
address, which is where the proxy listens. Plain HTTP to the name reached the proxy and got a 404.
|
||||
HTTPS failed in the handshake, and the proxy logged that it had no route for the name.
|
||||
|
||||
**The route as the proxy received it.** The mesh-written route file held a complete contribution
|
||||
for the module: endpoint `web`, port, scheme `https`, `insecure`, a label, and `internal-name`. It had
|
||||
no `name`. That is what the controller composes for an endpoint whose reach stops at the private
|
||||
network (ADR 0138, `composeName`). The contribution was correct.
|
||||
|
||||
**Located: `routesFrom` in the proxy.** It reads `name` first and skips the contribution if `name`
|
||||
is empty. It reads `internal-name` only at the end, as a second host for a rule that already has a
|
||||
public one. So the proxy can serve an internal name only next to a public one. That matched the
|
||||
mesh before ADR 0138, when both names were always composed. It has been wrong since then.
|
||||
|
||||
The other half of the proxy already handles the case. Certificates for a host are split by whether it
|
||||
is in the public set: hosts outside it go to the internal authority, and only hosts inside it are
|
||||
eligible for ACME. A host that is only ever an internal name falls on the correct side of both checks
|
||||
without change. For certificates, only reading the route was wrong; who may reach the route is the next section.
|
||||
|
||||
**The fix.** `routesFrom` takes a route that names either host, serves each name it carries, and
|
||||
marks only the public one as public. It still skips a route that names neither, with the same log line.
|
||||
A test proves an internal-only route is served, certified by the internal authority, and refused by
|
||||
the public one. That test fails against the code before the change.
|
||||
|
||||
## The first fix would have made the name public — 2026-10-02, from review
|
||||
|
||||
Serving the dropped route was not enough. The proxy picks a route from the name a request carries
|
||||
and never from where the request came from, and it answers public and internal names on the same
|
||||
listeners. Its public names resolve to an address the internet reaches. So once the internal-only
|
||||
route was served, any request from the internet carrying `unifi.home-server.internal` — a name of a
|
||||
fixed, guessable shape — would have reached an administration interface that reach `internal` was
|
||||
chosen to keep private. Before the fix the route was unreachable from everywhere. After it, it would
|
||||
have been reachable from everywhere. Two more leaks came with it: the proxy's answer for an unrouted
|
||||
name listed every name it serves, internal ones included, and the handshake handed a certificate
|
||||
naming the internal host to any client.
|
||||
|
||||
Nothing showed this while every routed endpoint also had a public name: its internal name exposed
|
||||
nothing the public one did not. It is a gap in the decision's wording, not only in the proxy — ADR
|
||||
0138 says the proxy *serves* the internal name without saying to whom — so it is recorded there as a
|
||||
progressive insight and in the to-be connectivity design.
|
||||
|
||||
**Where "inside" is decided: told, not worked out.** The first correction had the proxy work it out
|
||||
for itself — the mesh's range from an environment variable the catalogue wrote, and the machine's
|
||||
container bridges from its own interfaces. That was a second definition of "the mesh", kept by one
|
||||
module beside the one the controller already has: it resolves "from the mesh" to every machine's
|
||||
address on the private network, and the packet filter is rendered from that list. Reviewed, it was
|
||||
replaced: [ADR 0167](../../02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)
|
||||
has every membership on the bus carry what its module receives and that list, and the proxy follows
|
||||
its membership. One composition, read by the filter and by the proxy.
|
||||
|
||||
The proxy reads the source address, where the guard reads the interface, because it cannot see the
|
||||
interface a request arrived on. A claimed source does not carry here: a connection needs its replies,
|
||||
and replies to a mesh address leave by the tunnel.
|
||||
|
||||
**What changed with it.** The internal name of a route that also has a public one is now served to the
|
||||
mesh only, like any other internal name. Outsiders have the public name, so nothing they could reach is
|
||||
lost. A container calling its own machine's internal name arrives from its container network and is
|
||||
refused; whether the mesh should issue those networks too is left open in ADR 0167.
|
||||
|
||||
**Order of release.**
|
||||
|
||||
1. The catalogue change, which gives the proxy a bus account. A machine running the proxy is not
|
||||
composed until its account is issued, so the account is issued straight after
|
||||
(`module issue route-proxy --node <machine>`), and then the machine is pushed.
|
||||
2. The controller and proxy change. The push after it publishes memberships that carry the routes and
|
||||
the mesh, and each proxy takes them. Until then, a proxy serves its file, and internal names to its
|
||||
own machine alone.
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
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:
|
||||
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:
|
||||
---
|
||||
|
||||
@@ -62,3 +62,10 @@ because the alternative was four machines that could apply nothing.
|
||||
- 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.
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
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?
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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`.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-02
|
||||
located-in: [mesh-host internal/outward (Links reported only the links carrying a default route)]
|
||||
fixed-by: mesh-host PR 66 (a link backed by a physical device is named outward, up or down), live 2026-10-02
|
||||
amended-design: []
|
||||
---
|
||||
|
||||
# 197 — A physical link that is down is not filtered when it comes up
|
||||
|
||||
## What was observed
|
||||
|
||||
A sweep of every machine's filter on 2026-10-02. A laptop-class machine connected by its radio has a
|
||||
wired port that was unplugged. Its filter guarded the radio and the tunnel, and accepted everything
|
||||
arriving on any other link:
|
||||
|
||||
```
|
||||
iifname != { "mesh0", "<radio>" } accept
|
||||
```
|
||||
|
||||
The wired port was not in the list. Plugged in, everything arriving on it would have been accepted,
|
||||
every port of the machine open to whatever network the cable reached. That would last until the
|
||||
machine reported again and was pushed a new filter.
|
||||
|
||||
## Why it matters
|
||||
|
||||
**The filter's one rule about links fails open.** [ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md)
|
||||
has the filter constrain what arrives from outside, and has the machine say which links face outside.
|
||||
Everything not named is treated as the machine's own, its containers and bridges. So a link the machine
|
||||
fails to name is not filtered at all. The host named only the links carrying a default route at the
|
||||
moment it reported. A cable plugged in later is the ordinary case for a laptop. A second wired network
|
||||
that never carries the default route, such as a direct link to a storage box, is never named at all.
|
||||
|
||||
## Open questions
|
||||
|
||||
- A virtual link that faces outside (a VPN client's interface, a USB tether that appears as a virtual
|
||||
device) has no physical device behind it. It is named only while it carries the default route. Is
|
||||
that enough?
|
||||
|
||||
## Resolved (2026-10-02)
|
||||
|
||||
Live on the affected machine after the host was delivered and one more push: its filter now guards the
|
||||
radio, the tunnel and the unplugged wired port, before anything is plugged into it.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Diagnosis
|
||||
|
||||
*2026-10-02.*
|
||||
|
||||
**Located in `mesh-host` `internal/outward`.** `Links` read the kernel's routing tables and returned the
|
||||
interfaces carrying a default route. An unplugged port carries none, so it was never reported, and the
|
||||
controller rendered the filter around the links it was given.
|
||||
|
||||
**The fix.** A link faces outside if it carries a default route **or** has a physical device behind it.
|
||||
The kernel lists every interface under `/sys/class/net`, with a `device` entry for one backed by
|
||||
hardware. A bridge, a veth, the tunnel and the loopback have none, so they stay the machine's own. The
|
||||
wired port is now reported up or down, and the filter guards it before anything is plugged in. Tested
|
||||
with a radio carrying the default route and an unplugged wired port beside a bridge, a veth, the docker
|
||||
bridge, the tunnel and the loopback: the two physical links are reported, nothing else.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-02
|
||||
located-in: [mesh-catalog modules/dnsmasq (listens on loopback and the machine's mesh address only), the home-server's DNS (a predecessor's dnsmasq configuration the mesh did not own), the home network's DHCP (hands out the home-server as every device's DNS)]
|
||||
fixed-by: mesh-catalog PR 214 (dnsmasq listens on addresses from a setting; docker's file takes no settings), mesh-controller PR 210 (the settings verb), mesh-catalog PR 215 (unifi network DNS tools), live 2026-10-02
|
||||
amended-design: []
|
||||
---
|
||||
|
||||
# 198 — The home network's DNS server ran outside the mesh, and the mesh's filter closed it
|
||||
|
||||
## What was observed
|
||||
|
||||
Every phone on the home Wi-Fi had no internet, while a laptop on the same Wi-Fi did. The router's
|
||||
DHCP hands every device the home-server's LAN address as its DNS server. The home-server's DNS daemon
|
||||
was listening on that address, and every query to it timed out. The router itself answered the same
|
||||
query at once. The laptop worked because it resolves through its own local resolver, not through the
|
||||
server DHCP names.
|
||||
|
||||
## Why it happened
|
||||
|
||||
The DNS daemon on the home-server was not the mesh's. It ran under a configuration file a predecessor
|
||||
generated, listening on loopback, the mesh address and the LAN address. The mesh's `dnsmasq` module was
|
||||
assigned to the other three machines and not to this one, so no module on the home-server declared
|
||||
port 53. Its filter opens only what a module declares, so DNS from the LAN was dropped. It started when
|
||||
the home-server applied the filter this morning, after nine hours of applying nothing
|
||||
([issue 194](../194-the-hosts-own-former-archive-stops-every-apply/00-report.md)).
|
||||
|
||||
Nothing said so. The daemon reported running, the filter applied cleanly, and the mesh had no record
|
||||
that the home network depended on a service it did not know.
|
||||
|
||||
## Why it matters
|
||||
|
||||
**A service the mesh does not know is closed by the mesh's filter, by design, and nothing asks whether
|
||||
something depends on it.** That is the right default for an unknown port. It is the wrong outcome for
|
||||
the one service a whole network was told to use. The gap is that a machine can run something
|
||||
important outside the mesh with nothing to show it.
|
||||
|
||||
**The mesh's `dnsmasq` could not have served the LAN either.** It listened on loopback and the mesh
|
||||
address only. The reach of its DNS endpoints opens the filter, but the daemon would not have been
|
||||
listening on the LAN address anyway.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should a machine report the listening services the mesh does not own, the way it reports the links
|
||||
that face outside? This one would have been visible before the filter closed it.
|
||||
- The LAN address the home-server answers on is now a setting, beside the reach that opens the filter.
|
||||
Two statements that must agree. Should reach `public` on a DNS endpoint imply listening beyond the
|
||||
mesh?
|
||||
|
||||
## Resolved (2026-10-02)
|
||||
|
||||
The home network was pointed at the gateway for DNS while the fix was built, which got the phones back
|
||||
within minutes. Then:
|
||||
|
||||
- the mesh's `dnsmasq` takes the addresses it listens on beside the machine's from a setting, with
|
||||
loopback as the mesh-wide default, so no other machine changed;
|
||||
- the home-server's layer adds its LAN address, and its DNS endpoints' reach is `public`. The router
|
||||
forwards no DNS, so that means the LAN;
|
||||
- the module and its sibling `resolv-conf` were assigned to the home-server, replacing the
|
||||
predecessor's daemon and configuration, which were kept aside;
|
||||
- the home network was pointed back at the home-server, through a new `unifi` tool.
|
||||
|
||||
Checked live: from another machine on the LAN, public names and mesh names both resolve through the
|
||||
home-server's LAN address, and the mesh and the machine itself resolve as before.
|
||||
|
||||
**One fault found on the way, and caught before it reached any machine.** A module's settings are
|
||||
merged into every mergeable file the module owns. The first attempt therefore put the new setting into
|
||||
docker's `daemon.json` as well as into dnsmasq's config, and dockerd refuses keys it does not know. The
|
||||
plan showed it before any push. The change was reverted and redone with docker's file declared to take
|
||||
no settings. The general fault, a module's settings reaching files they were not meant for, is still
|
||||
there for any module with more than one file.
|
||||
Reference in New Issue
Block a user