Two records, from building it. 0009 has a section titled "there are no domain modules", and `networking` now exists. It is not a contradiction and it reads as one, so the difference is written down: what was refused contains WireGuard and a proxy and is assigned where half of it is unwanted. What exists contains nothing — requirements and a name — so there is no half. Every artifact it leads to is still an ordinary module assigned on its own terms. With the cost stated, because it is real: adding a second implementation turns a settled question into an open one for everyone using the bundle, not only for whoever wanted the alternative. That is the refusing rule applied consistently, and the alternative is a default, which is the flavor field returning under a better name. 08-connectivity gains why the network stopped being code beside the module system: a machine was on the private network because it had an address, and there was no way to keep one off. A manifest can now say its resources are computed, which is what a peer list needs. And three modules rather than one, because WireGuard is one VPN of several. Naming a module after the job and putting one implementation inside it is flavor wearing a generic name — the second VPN has nowhere to go.
264 lines
14 KiB
Markdown
264 lines
14 KiB
Markdown
---
|
|
topic: what runs on it
|
|
status: accepted
|
|
date: 2026-08-28
|
|
deciders: jochen
|
|
reconstructed: false
|
|
---
|
|
|
|
# 9. Modules and the graph
|
|
|
|
*Consolidated 2026-08-28 from six records.*
|
|
|
|
## Everything is a module
|
|
|
|
One kind of thing, one manifest describing all of them. A database, a web application, a window
|
|
manager and a firewall rule set are all modules — not because they are alike, but because
|
|
**anything else means a second kind of thing with its own rules, and then a third.**
|
|
|
|
**A module is the unit of delivery**: assignable to a node, versionable, replaceable on its own.
|
|
|
|
## There are no domain modules
|
|
|
|
An earlier decision grouped modules by domain — four things constituting *how a node is
|
|
reachable* becoming one `networking` module. **That was wrong, and the correction is worth
|
|
keeping** because the observation behind it was right.
|
|
|
|
The measurement holds: reachability is the **only** place in the catalogue where modules
|
|
genuinely change together under one intent. What did not hold is the conclusion. Tight coupling
|
|
means they share an **authority** — one place that decides for all of them — and not that they
|
|
should be one artifact. `wireguard` and the proxy are deployed to different sets of nodes, so a
|
|
module containing both would be assigned where half of it is unwanted.
|
|
|
|
> **Coherence is a context. Delivery is a module.**
|
|
|
|
**Folders assert relationships; edges record them.** What grouping was for — finding things,
|
|
seeing what belongs together — is a tag and a query, neither of which anybody has to keep true by
|
|
hand.
|
|
|
|
### What a domain module turns out to be, and why it is not the one refused above
|
|
|
|
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
|
|
exists and is not one — but only if the difference is stated, so it is stated here.*
|
|
|
|
**What was refused contains things. What exists contains nothing.**
|
|
|
|
| | `networking` as refused | `networking` as built |
|
|
|---|---|---|
|
|
| what is in it | WireGuard, a proxy, a firewall — artifacts | nothing at all |
|
|
| what it says | *these ship together* | *I want a private network and names* |
|
|
| what is assigned | one module, half of it unwanted | whatever answers each requirement, each on its own |
|
|
|
|
The objection above is untouched by this and still correct: a module holding WireGuard and a
|
|
proxy is assigned where half of it is unwanted. **A module holding nothing cannot be, because
|
|
there is no half.** It is requirements and a name, and every artifact it leads to is still an
|
|
ordinary module assigned on its own terms.
|
|
|
|
**Why it is worth having.** Most people want the network working and do not want to choose a VPN.
|
|
`assign networking` finds one answer to each requirement and takes it without asking, because
|
|
with one candidate there was never a question — the rule below about refusing does the work.
|
|
Somebody who does care assigns the VPN they want, and *that is the whole of choosing*: there is no
|
|
flavor field, no variant syntax, and no second verb. **Picking an implementation is assigning a
|
|
module.**
|
|
|
|
**What it costs, stated because it is real.** Adding a second implementation to the catalogue
|
|
turns a settled question into an open one for **everyone using the bundle**, not only for whoever
|
|
wanted the alternative. Every node assigned `networking` refuses until somebody says which. That
|
|
is [the refusing rule](#a-requirement-with-several-answers-is-refused-never-guessed) applied
|
|
consistently, and the alternative is a default — which is the flavor field returning under a
|
|
better name. The cost is one assignment per node, and the message names the candidates.
|
|
|
|
**A consequence that had to be found by running it.** A bundle can drag an implementation in
|
|
through a requirement nobody looked at. Choosing a different VPN still installed WireGuard,
|
|
because the names module needed addresses only WireGuard hands out, and nobody was told. Two VPNs
|
|
on one machine is not always wrong — a machine may run one for another purpose — but being **the**
|
|
network the mesh runs over is singular, so that is a claim, and the collision is refused by name.
|
|
**The general rule: what a bundle pulls in is only as safe as the claims on what it pulls in
|
|
from.**
|
|
|
|
## Three edges
|
|
|
|
| edge | means | declared? | satisfied |
|
|
|---|---|---|---|
|
|
| **presence** | that thing must exist and be reachable here | yes | at provisioning |
|
|
| **instantiation** | that thing makes something for me and hands back credentials — a database, a bucket, a route | yes | at provisioning, and again whenever it must be |
|
|
| **build** | I was compiled against that artifact | **no — read from imports** | **at build, once** |
|
|
|
|
**Instantiation implies presence; presence does not imply instantiation.**
|
|
|
|
**A route is an instantiation edge**, and it is worth noticing because the direction is the mirror
|
|
of a database: the consumer supplies a target and receives a *name*, rather than supplying nothing
|
|
and receiving credentials. Same edge.
|
|
|
|
**Provider stops being a category.** Any hosted thing can be a factory — an identity provider
|
|
grants clients, a mail server grants mailboxes. It is a facet, not a kind.
|
|
|
|
**A module may also declare what it claims**, because some things cannot coexist and that is a
|
|
fact about the module rather than about a particular node. What that means precisely is below.
|
|
|
|
### Why the build edge is a different kind
|
|
|
|
It is fixed inside an artifact rather than negotiated when something runs, and **its only remedy
|
|
is a rebuild** — nothing can re-provision it.
|
|
|
|
It is also **derived rather than declared**, and the asymmetry is deliberate: a runtime edge is an
|
|
*intention* somebody has about how the mesh should be wired, and only a person can state it. A
|
|
build edge is a *fact about code that already exists*, and a declared list of dependencies drifts
|
|
from the imports it describes.
|
|
|
|
**An artifact is out of date when its source moved, or when anything it was built against moved.**
|
|
So what is recorded is a commit *and the identity of every artifact it was built against*, which
|
|
is what makes the rebuild set computable and *is this current?* answerable without building.
|
|
|
|
**The graph measures design quality, not just build order.** A module with many inbound build
|
|
edges is one whose every change is expensive — and that is readable before anything is built. The
|
|
current shared library is exactly that, and nobody could see it because nothing drew the edges.
|
|
|
|
## Provisioning is declared, never configured by hand
|
|
|
|
A module declares what it **provides** and what it **requires**. The mesh satisfies it: a
|
|
provisioner belonging to the provider creates the resource and its credential, records the grant,
|
|
and the values are derived onto the consumer. **Neither the credential nor the topology is ever
|
|
written by hand.** A requirement may name a provider on another node, so cross-node wiring is the
|
|
same declaration.
|
|
|
|
## What a module claims, and why it is not a list of rivals
|
|
|
|
*Written 2026-08-29, replacing pairwise exclusion.*
|
|
|
|
**Exclusivity is not a property of a module. It is a property of a singular resource the module
|
|
takes over.** Two shells do not compete for anything and any number may be installed. Two display
|
|
servers both want the seat, and only one may have it.
|
|
|
|
> **A module declares what it *claims*. Two modules claiming the same thing cannot both be
|
|
> assigned within that claim's scope.**
|
|
|
|
**Not "xorg conflicts with wayland".** Pairwise exclusion has a property that only shows up later:
|
|
adding a third display server means **editing xorg and wayland to know about it**. Every new
|
|
module requires changing modules nobody who wrote it owns, and the edits grow as the square of
|
|
the count. With a claim, the third one says `claims: the seat` and nothing else changes anywhere.
|
|
**The new module is the only thing that has to know anything** — which is the difference between
|
|
a catalogue that grows and one that calcifies.
|
|
|
|
The pattern is common enough to be worth listing, because seeing it is most of understanding it:
|
|
|
|
| these coexist | these claim one thing |
|
|
|---|---|
|
|
| shells — bash, zsh, fish | display servers — xorg, wayland (*the seat*) |
|
|
| editors — vim, emacs, helix | init — systemd, openrc (*pid 1*) |
|
|
| language runtimes | container runtime — docker, podman |
|
|
| terminal emulators | reverse proxies — nginx, caddy, traefik (*ports 80/443*) |
|
|
| browsers | time — chrony, timesyncd, ntpd (*the clock*) |
|
|
| | resolvers — resolved, dnsmasq, unbound (*`/etc/resolv.conf`*) |
|
|
| | network management — NetworkManager, networkd, netctl |
|
|
| | mail — postfix, exim, msmtp (*port 25*) |
|
|
| | audio — pipewire, pulseaudio (*the device*) |
|
|
|
|
**A claim has a scope**, because not everything singular is singular per machine:
|
|
|
|
| scope | example |
|
|
|---|---|
|
|
| **node** | the seat, pid 1, port 443 |
|
|
| **site** | a DHCP server on a segment |
|
|
| **mesh** | the hub, the control plane |
|
|
|
|
The last is not new — the mesh already enforces exactly one hub with a unique index
|
|
([ADR 0007](0007-connectivity.md)). Scope is that idea, said once rather than hard-coded per case.
|
|
|
|
**Some conflicts need no claim at all.** Two modules declaring the same file, or binding the same
|
|
port, are visible from *what they declare* — the mesh already holds every resource of every
|
|
declaration. So a claim is only written for the abstract ones, where nothing in the declaration
|
|
reveals the clash. That keeps the manifest small, which is worth protecting.
|
|
|
|
## A requirement with several answers is refused, never guessed
|
|
|
|
A module requiring *a shell* may be satisfied by three. The mesh does not pick.
|
|
|
|
| candidates | what happens |
|
|
|---|---|
|
|
| exactly one | assigned, silently — there was no choice to make |
|
|
| none | refused, naming what is missing |
|
|
| several | **refused, naming them**, and a person chooses |
|
|
|
|
**This is what makes a solver unnecessary.** Counting candidates is a few lines and has no
|
|
surprising behaviour; a solver that picks has to be understood before its answer can be trusted,
|
|
and it is understood by whoever is debugging it at the time. Nothing here is lost by waiting —
|
|
a solver can be added later without changing a single manifest, and the reverse is not true.
|
|
|
|
**Requiring a module and requiring a capability are different fields**, because the remedies
|
|
differ and the message should say which:
|
|
|
|
- *i3 needs xorg, which is not assigned here* — assign it.
|
|
- *this machine has no seat* — wrong machine; nothing can be installed to fix it.
|
|
|
|
### A capability may carry a value, and that is not a new idea
|
|
|
|
A capability is a named fact about a machine, **detected and never assumed**. Its presence gates
|
|
an assignment; its detail can also carry a value — `seat: card1-DP-1`, `panel: oled`, an
|
|
architecture, an amount of memory. Nothing new is needed for that: a verdict has always had a
|
|
detail beside its yes or no.
|
|
|
|
So *can this run here* and *what should it be configured as* are answered by the same fact, read
|
|
two ways. A module that must not be assigned without an OLED panel and one that dims itself
|
|
differently on one are reading the same line.
|
|
|
|
**What keeps the set from sprawling is the cost of adding one.** A capability must be detected,
|
|
and the detector must say how it knows — so nobody can add one they cannot check, which is the
|
|
whole of [04-ISSUES/007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md):
|
|
an installed package was treated as a capability and a node was assigned work it could not do.
|
|
|
|
**And detectors ship inside the host**, which is one statically linked binary. Adding a capability
|
|
means shipping a new host to every node that needs it. That is a real cost and it argues for
|
|
keeping the vocabulary small and general — `seat`, not `has-nvidia-with-two-outputs`.
|
|
|
|
## "Flavor" is retired
|
|
|
|
It was carrying three unrelated meanings — variants of a thing, a subset of one module a node
|
|
installs, and whatever the current system does, which earned two knowledge-base entries about
|
|
going wrong. **A word with three meanings cannot be reasoned about**, and every attempt to design
|
|
around it produced a rule that was right for one meaning and wrong for the others.
|
|
|
|
What it was reaching for is two ordinary things:
|
|
|
|
- **Different modules that provide the same thing.** `zsh` and `fish` both provide *a shell*. They
|
|
are two modules, not one module with a switch: they share a name and nothing else — different
|
|
packages, different configuration, different everything.
|
|
- **One module with a setting.** A monitoring module that is an agent here and a server there is
|
|
one module, configured. Nothing varies but a value.
|
|
|
|
If something is neither, it is probably two modules.
|
|
|
|
**A third thing it was reaching for, added 2026-08-29:** *I want this working and I do not care
|
|
which one.* That is a module with requirements and no files —
|
|
[a domain module](#what-a-domain-module-turns-out-to-be-and-why-it-is-not-the-one-refused-above) —
|
|
and it is what makes "different modules that provide the same thing" bearable for somebody who
|
|
does not want to know there is a choice.
|
|
|
|
## The core library is the mesh's domain
|
|
|
|
One module everything may depend on. It holds **what is true of the mesh regardless of which
|
|
context you are in**: a module, a node, an assignment.
|
|
|
|
The test: *would this still mean the same thing in a context that had never heard of the one it
|
|
came from?* A node would. A pipeline stage would not — that is delivery's.
|
|
|
|
**Types ship with the module that owns them**, not here. A consumer needing `inventory`'s types
|
|
depends on `inventory` — one narrow, visible edge — rather than everything depending on a hub
|
|
where the relationship cannot be seen. **A library everything depends on is expensive to change
|
|
whether it holds types or code; the fan-in is what makes it expensive**, which is why *types, not
|
|
behaviour* was the wrong guard.
|
|
|
|
**It stays small on its own.** A domain model changes when what the mesh *is* changes, which is
|
|
rare. A drawer labelled *shared* changes whenever anybody writes something reusable, which is
|
|
constantly — and *who else might want this* always answers yes, which is how the current one grew.
|
|
|
|
## Consequences
|
|
|
|
- **Fewer things will be shared, and some code will be written twice.** That is the trade: the
|
|
current library exists because sharing felt free. Two similar functions in two modules is often
|
|
the better answer.
|
|
- **The check is a measurement rather than a prohibition.** Inbound build edges say when something
|
|
is becoming a hub, while it is happening rather than after.
|
|
- **Reading build edges needs a language-aware tool per language**, which is the real cost and the
|
|
reason declaring them looks tempting. It is still wrong.
|