The predecessor's agent module was retired and its six files stayed on both workstations telling every session to use tools that no longer exist. Before a successor module is written, the design needs the decisions it rests on and nothing in the record stated them: - ADR 0169 (reconstructed) records what the controller shipped on 2026-09-27 without a record: the operator account is a node fact stated by the operator, the home is derived unless stated, a resource may be placed under it owned by the account, and a node with no account refuses one. - ADR 0170 generalises to-be 29 §3's found-vs-owned boundary to every directory under a home: the module owns the directory and the files it places, writes into the tool's own files for its few keys, never declares a credential's content, and holds everything else as found — a predecessor's leftovers included, which the operator removes once. - ADR 0171 draws the licence line the operator asked to have drawn rather than assumed: the mesh binds and delivers (to-be 14 and 15 stand), the module alone writes the credential file, refresh stays central (ADR 0050), a switch is the binding changed through a controller seat verb asked for via the console, and the token-carrying shell helper is retired. The controller learns nothing about the agent; that is what "no part" means. To-be 36 is the module's design: the ownership map per path, the fate of the six predecessor files, what the three instruction documents say, the licence tools and skill, the console as a node-scoped provision, the package gap stated honestly, and the order of the build. To-be 14, 29 and 34 carry dated notes; the glossary gains "operator account".
234 lines
15 KiB
Markdown
234 lines
15 KiB
Markdown
---
|
||
layer: to-be
|
||
status: in-progress
|
||
code:
|
||
- mesh-controller internal/inventory
|
||
- mesh-controller internal/catalogue
|
||
- mesh-controller cmd/mesh-controller
|
||
updated: 2026-10-02
|
||
decisions:
|
||
- 02-DECISIONS/0176-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
|
||
- 02-DECISIONS/0177-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
|
||
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
|
||
- 02-DECISIONS/0117-a-machines-uplink-is-a-seat.md
|
||
---
|
||
|
||
# 29 — A node has operator accounts, and the mesh owns what lives under a home
|
||
|
||
**The mesh models machines but not the people on them.** A node record holds its name, its
|
||
address, its mode — and nothing about *who a person is* on it: one login name on the build node,
|
||
another on the home-server, a third on both workstations. That username is not incidental. It
|
||
decides who a file under `~` is owned by, who a user service runs as, and — the case that surfaced
|
||
this — which account `ssh <node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
|
||
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 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
|
||
|
||
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) 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): the `Host` blocks 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 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 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`.
|
||
|
||
## 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_hosts` becomes 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` / sshd `TrustedUserCAKeys` becomes one trust line — no N×N key spraying — and
|
||
short-lived certs give rotation for free
|
||
([ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md)).
|
||
- The **CA private key is the mesh's**, a secret the vault makes
|
||
([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)).
|
||
|
||
**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](../../02-DECISIONS/0051-shared-data-is-the-operators.md). 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 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 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.
|
||
|
||
## 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 `~/.ssh` per §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.
|
||
|
||
## 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 0176](../../02-DECISIONS/0176-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 0177](../../02-DECISIONS/0177-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
|
||
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 0120 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 `sshd` boundary.** 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`/`IdentityAgent` in `config`, 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 and `ProxyJump` over forwarding.
|
||
|
||
**Now load-bearing.** The migration of every node to the mesh is complete; what remains of the
|
||
predecessor is exactly the user environment this design covers — ssh config, dotfiles, the desktop
|
||
stack and the per-user services of the two workstations. Those generators are the last thing
|
||
keeping the predecessor running, so the model questions above are no longer deferred: the account
|
||
record, the home as a placement root, user-scoped services and the one-off steps a hook used to run
|
||
each need a decision before the modules that replace the generators can be written.
|
||
|
||
## References
|
||
|
||
- The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s
|
||
postConfigure hook), which the nox mesh has no equivalent for.
|
||
- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
|
||
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.
|
||
- [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) — why the operator's personal
|
||
key is signed, never minted.
|
||
- [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the
|
||
vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md)
|
||
— short-lived certs as rotation.
|
||
- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
|
||
[ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
|
||
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`.
|