--- 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)