Files
hq/02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
T
jschoubben 0f7f628730 ADR 0120: note the shared/region interaction with hq 128
A roster fact may be shared — written into a marked region of the machine's
file (into: block, hq 128) rather than as the whole file. The template
renders the content; shared decides how the host lays it down. Composes with
hq 128: the region mechanism is the host's, the format is the module's.
2026-09-27 01:42:00 +02:00

137 lines
8.9 KiB
Markdown

---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: false
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
---
# 120. A roster fact carries its format as a template: the mesh owns the data, the module owns the format
## Context
A **fact** is a thing only the mesh knows — which machines exist, what they are called, where they
are — written into a file where a module asks for it. The mesh computes it from the graph; a module
loads it, restarts on it, does what its software does with it. Facts replaced three modules that
existed only because computed output needed somewhere to live and ran no software of their own
([ADR 0040](0040-what-a-module-is.md)).
But the *format* lived in the control plane. A fact was a name from a closed list, and each name had
a formatter written in Go beside the others: `node-names` wrote the roster as an `/etc/hosts` file,
`node-zones` wrote it as a dnsmasq resolver's `local=`/`address=` lines. Adding a consumer meant
adding a formatter — in the consumer's own configuration language — to the mesh.
The ssh work made the cost plain. An operator's `~/.ssh` wants three roster projections — a
`known_hosts`, an ssh `config` of `Host` blocks, an `authorized_keys` — each in ssh's syntax. Under
the closed list that is three more formatters in the control plane, teaching it ssh's configuration
language. And it does not stop at ssh: every daemon that reads the roster in its own file format
would put its grammar here. The control plane was accreting the configuration languages of software
it does not run — the exact thing [ADR 0040](0040-what-a-module-is.md) says is a
module's and not the mesh's.
The shape underneath is one shape. WireGuard's `[Peer]` blocks, `/etc/hosts`, dnsmasq's zones, an
ssh `known_hosts` — all of them are *the roster, projected into a file*. Only the projection differs,
and the projection belongs to whoever runs the software that reads it.
## Considered Options
**1. Keep the closed list; add a formatter per consumer.** Rejected. The control plane learns the
configuration language of every daemon any module might run, without bound, and each format lives in
the mesh rather than in the module that owns the file. A module cannot change how its own file is
written without a control-plane change.
**2. A general placeholder vocabulary over `content`, like `${machine:address}` but for the
roster.** Rejected. The mesh's other substitutions each resolve to *one* scalar — this machine's
address, one provider's port. The roster is inherently a *repetition*: one block per machine. A flat
`${…}` vocabulary cannot iterate, and a mechanism that could would be a template in all but name.
**3. The module gives a path and a template over the roster; the mesh renders it.** Chosen. The mesh
owns the data — who exists, their names and addresses — and hands it to a Go `text/template` the
module wrote. The mesh renders and reads neither the template's intent nor the file's meaning.
## Decision
**A fact is a path and a template.** In a module's manifest, `facts` maps a name the module chooses
to a `{ path, template }`. The template is a Go `text/template` over a fixed **roster view**:
- `.Node` — this machine's bare name.
- `.Suffix` — what a mesh name ends in (`internal`, or the operator's choice), as composed.
- `.Names` — every name the mesh serves: the machines *and* the names it was told to route.
- `.Machines` — only the machines that are nodes of this mesh.
Each of `.Names` and `.Machines` is a list of `{ Name, FQDN, Address }`. A machine the mesh has a
record for but cannot yet place has no address and is left out of both — a name that resolves to
nothing is a connection that hangs, so it is omitted rather than written (the same rule as before).
**The mesh owns the data; the module owns the format.** The control plane holds **no** formatter.
The two built-in projections render through the same path any module uses:
- **`/etc/hosts`** is a template on the mesh's own network module. The mesh writes `/etc/hosts`
because being on the private network is what gives a machine a name — but the *layout* is a
template like any other, shipped with the control plane because that module ships with it, not
because the control plane knows the hosts-file format.
- **dnsmasq's zones** move into dnsmasq. The `local=`/`address=` grammar is dnsmasq's configuration
language, and it now lives in dnsmasq's manifest, where the module that runs dnsmasq owns it.
**A fact also says whether its file is the mesh's whole or a region of the machine's.** A hosts file
is the machine's — its `localhost`, the operator's lines, another tool's marked blocks — so
`node-names` is `shared`: the mesh owns only its region and keeps the rest byte for byte, the host
laying it down `into: block`
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), hq issue 128). A resolver's
zones file is the mesh's whole, and is not shared. The template renders the content either way;
`shared` decides how the host writes it. This composes with hq 128 rather than replacing it: the
region *mechanism* is the host's, the region's *format* is the module's template.
**The names-vs-machines distinction is the template's choice** ([04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md)):
a container's hosts ranges `.Names`, so a routed name resolves to the machine serving it; a resolver
told the mesh's suffix is its own ranges `.Machines`, or a routed name written there with the suffix
appended is a name nobody will ever ask for.
**A template that will not render is refused at composition, not on a machine.** A template that does
not parse, or reads a field the roster does not have, fails where the manifest is — the closed-list
safety, moved from the fact's *name* to the roster's *shape*. A daemon that starts, reads a file the
mesh could not render, and answers nothing is a much worse way to find out.
**WireGuard stays a computed generator, and that is the line.** Its `mesh0.conf` is not a pure roster
projection — it carries topology the control plane decides: which peers are reachable, endpoints, hub
forwarding, keepalive for a NAT'd node. And it is *foundational*: the overlay must be up before any
module can be delivered, so the thing that writes it cannot itself be a delivered module. The line
this draws: **the substrate that delivery rides on is the control plane's; everything layered on a
working overlay is a roster template.** DNS, hosts, and ssh are layered; the overlay is the floor.
## Consequences
- **ssh is two templates and no control-plane change.** Once the roster view carries a machine's ssh
host key and its operator account ([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)),
`known_hosts`, the ssh `config`, and `authorized_keys` are templates on the ssh modules — the mesh
gains no knowledge of ssh's syntax. This ADR is what makes that work land without touching the
controller.
- **A new roster projection never touches the control plane.** Any module that reads the roster in
its own format ships its own template.
- **A module can change how its own file is written** without a control-plane change — it is editing
its own manifest.
- **The schema changed and is not backward compatible.** A fact was a string (a path); it is now
`{ path, template }`. The old string form has no template and cannot be auto-upgraded, because the
format it implied was the formatter this ADR deletes. The controller and every catalogue module
using facts — only dnsmasq — land together. A controller and a catalogue that disagree cannot
compose the module: the running daemon on a machine is unaffected, but the mesh will not send it a
new declaration until both sides agree.
- **The output did not change.** The `/etc/hosts` and dnsmasq zones a machine receives are
byte-for-byte what the deleted formatters wrote, pinned by tests that render the built-in template
and compose the real dnsmasq manifest.
## References
- [ADR 0040](0040-what-a-module-is.md): a module is software the mesh runs — a
format the mesh knows for software it does not run was the accretion this stops
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a module definition names no
path; this is its sibling for content — a module definition names no format the mesh must know
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md): the ssh consumer this
unblocks, and the roster fields it will add
- [04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md): every served name is not a
machine — now the template's choice of `.Names` or `.Machines`
- mesh-controller `internal/catalogue/roster.go` (the mechanism), `internal/overlay/generator.go`
(the built-in `/etc/hosts` template), `internal/catalogue/manifest.go` (`RosterFile`)
- mesh-catalog `modules/dnsmasq/module.json` (the zones template, dnsmasq's own)