Both lines of work numbered from the same point, so four decision records and one design document existed twice with different content. The trunk keeps its numbers and this branch yields — the only rule that scales, because the trunk's are already cited by what merged before them. 0117 the bus is the only broker -> 0125 0118 a module declares its own seats -> 0126 0119 amqp is a provision, not the bus -> 0127 0120 the mesh bus is required -> 0128 0123 a seat carries its role's protocol -> 0129 0124 the predecessor is ending -> 0130 design 29, what a module declares -> design 32 Applied to the code repositories too, because a stale reference is worse when numbers collide than when they dangle: the reader lands on a real record that decided something else. Two reconciliations the merge forced, both real: **0110 was marked wholly superseded and was not.** Its successor says in as many words that everything 0110 decided about what a seat *is* stands untouched — and two records that landed on the trunk rest on exactly that part. So it is accepted again, extended rather than replaced, with a note saying which of its claims moved and where. **A seat's protocol becomes columns, not fields.** The trunk moved the seat set out of compiled code into a table the controller owns. This branch had added what a role accepts, emits and serves to the Go slice. The decision is unaffected and the mechanism is better for it: giving a role a protocol is now a write rather than a rebuild, which is the trunk's own argument applied to what this branch added. One check still fails and it fails on main too: a record resting on ADR 0112 while that is still 'proposed'. Left alone — it is not this merge's to answer.
12 KiB
layer, status, code, updated, decisions
| layer | status | code | updated | decisions | |||||
|---|---|---|---|---|---|---|---|---|---|
| to-be | proposed | 2026-09-27 |
|
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
person's ~/.ssh/config, ~/.zshrc, ~/.config); the mesh, taking those over, kept the machine
facts and dropped the human one.
Several things are missing, and they are one idea.
1. The account is a node fact
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.
2. A resource may live under a home, owned by its account
ADR 0112 placed a
module's system data — <root>/<module>, owned by the module. It has no analog for the other
half of the filesystem: the things that belong under a person's home and are owned by that
person. ~/.ssh/config, ~/.zshrc, ~/.config/hal — every one of these is a resource the mesh
should be able to place and own, resolved against the account's home rather than a system
root, and chowned to the account rather than to root or a module uid.
This is the same move as ${dir:…}, one level over: a resource says home: <account> (or names
an account requirement), and the mesh resolves the home directory and the owning uid on the node
that account lives on. A module that writes operator config — the eventual replacements for
hal/terminal, hal/claude-code, hal/secrets — declares its files this way and names no
/home/... path, exactly as a system module now names no /var/lib path.
These are a family, not one module: an ssh-client module, a shell module, a ~/.config
module, each a universal-tier consumer of the account fact — assigned wherever a person logs in,
which is every node, unlike the graphical stack that a capability gates.
3. The whole of ~/.ssh is the mesh's — with one boundary drawn inside it
The predecessor owned a single file (~/.ssh/config) and left the rest alone; it drifted, because
owning one file beside foreign ones is not owning anything. The mesh should own the directory:
create ~/.ssh at 0700, chown it to the account, and own the files it places there —
config(or the mesh's region of it): theHostblocks for every other node, composed from the roster;known_hosts: authoritative, so the "Host key verification failed / accept-new" dance that cost real time during enrolment simply ends;authorized_keys: who may log into this account, governed centrally rather than by whichever key happened to be pasted where.
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,
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 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.
4. Keys are the mesh's to generate — through a CA, and existing keys are adopted, not replaced
Key generation is the mesh's, not each node's improvising its own. The clean form is an SSH certificate authority as a seat, the sibling of the TLS internal CA the mesh already runs:
- Host certs. The mesh signs each node's host key. Every node's
known_hostsbecomes one line —@cert-authority *.<suffix> <mesh-CA-key>— and nothing is distributed per node; a new node is trusted the instant its host key is signed. - User certs. The mesh signs a cert naming the principals (accounts) allowed. Every node's
authorized_keys/ sshdTrustedUserCAKeysbecomes one trust line — no N×N key spraying — and short-lived certs give rotation for free (ADR 0114). - The CA private key is the mesh's, a secret the vault makes (ADR 0113).
Three kinds of key, and only one is never minted. Host keys (server identity) and pure machine-to-machine keys the mesh may generate end to end. The operator's personal private key — possibly on a hardware token, possibly used from an off-mesh laptop — the mesh signs into a cert but never generates; that, and only that, is the residue of ADR 0051. So "keys are mesh-owned" and "the operator's login key is the operator's" reconcile: the mesh owns the CA and the signing; it holds the human's private half, never mints it.
Existing keys are not lost. Taking ownership is adoption, not regeneration: a key already on a
machine is recorded and signed, not overwritten. The mesh gains authority over ~/.ssh — it does
not clear it. An enrolling node's host key and the operator's existing key are carried forward; the
found-vs-owned boundary of §3 is exactly what guarantees nothing already there is destroyed.
5. How it is distributed: the controller composes, the node applies
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): 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
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.
6. The two modules, and the seat between them
sshd(server, every node) — manages sshd, owns and reports its host key so the roster carries it, and trusts the user CA.ssh-client(client, every node) — owns~/.sshper §3, consumes the roster and the CA public key.the-ssh-ca(a seat, held on the control node) — signs host and user certs.
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.
Why now, and why not yet
Why it matters: when HAL retires, the generators that keep ~/.ssh, shell config and the
operator's ~/.config/hal current retire with it. Without this, adding a node stops adding its ssh
alias and its trust, and a fresh machine has no operator dotfiles at all — the mesh would run every
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 ssh files be templates with no control-plane format — so what remains to decide here is the model:
- One account or several per node? A workstation has one human; a shared box might have more. Allow more than one without forcing the common case to name it.
- The CA's shape. Host-cert and user-cert principals, cert lifetime and renewal, where the CA runs (a seat on the control node). The one thing fixed: the operator's personal key is signed, never minted.
- Adoption of existing keys. How an enrolling node's host key and an operator's existing key are recorded and signed rather than replaced — the found-vs-owned boundary, made concrete for keys.
- The
sshdboundary. Server side exists; this is the client, the identity, and the CA. - The ssh-agent. An agent is a user-scoped service running as the account — the first concrete
case of the user services §2 anticipates. It holds the operator's private key in memory; the mesh
declares the unit and sets
AddKeysToAgent/IdentityAgentinconfig, and still never sees the private half. Agent forwarding wants a policy, not a default: with user certs it is largely unnecessary, and forwarding an agent into a node exposes the operator's keys to that node's root — so prefer certificates andProxyJumpover 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.
References
- The gap was found generating
~/.ssh/configfrom the HAL registry (hal/terminal's postConfigure hook), which the nox mesh has no equivalent for. - ADR 0128 — the roster fact mechanism that renders the ssh files, format owned by the module.
- ADR 0112 — the system-path placement this mirrors for home paths.
- ADR 0051 — why the operator's personal key is signed, never minted.
- ADR 0113 — the CA key is a secret the vault makes; ADR 0114 — short-lived certs as rotation.
- ADR 0125,
ADR 0126 —
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to
~/.ssh.