Settles the design repository now that the self-upgrade build is on main: - Records the two decisions that shipped without a record — ADR 0077 (the controller/foundation/node vocabulary) and ADR 0078 (the store and broker are ordinary modules); accepts ADR 0075 and 0076, which shipped work rests on. - Fills issue 051's amended-design and wires ADR 0078 into 07-the-foundation. - Sweeps the repo rename (mesh-control -> mesh-controller) into the mutable docs now that the forge repo is renamed; updates the glossary note and repos.md. - Fixes the six broken links from the design-doc renames, indexes the glossary, regenerates the decisions reading order. Both checks (records.py, index.py) are green. Statuses stay honest: the build is on main and lab-proven but not deployed as the production mesh, so the to-be docs remain in-progress and the as-is layer (the hal mesh) is unchanged — graduation to implemented + as-is belongs to deployment, not merge. https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
108 lines
5.9 KiB
Markdown
108 lines
5.9 KiB
Markdown
# Playbook 06 — Writing a module
|
|
|
|
**Trigger.** Something that runs today must run on the mesh, or a new capability must be
|
|
declarable.
|
|
|
|
**Who runs it.** Whoever is porting or writing it.
|
|
|
|
*Written 2026-09-01 from doing this for the first time end to end. Every step below exists
|
|
because skipping it cost something.*
|
|
|
|
## Before anything: read what runs
|
|
|
|
**A module is written from the thing, not from memory of the thing.** For a port, that means its
|
|
current compose file, its environment, and where its data actually sits. Assumptions about any of
|
|
the three have been wrong every time they were not checked.
|
|
|
|
Three questions, answered from the machine:
|
|
|
|
| | why it decides something |
|
|
|---|---|
|
|
| **what containers, and how do they find each other?** | more than one means a `network`; names between them must match what the software is configured to dial |
|
|
| **where is its data?** | a bind mount moves with a path; a named volume does not; an anonymous volume is already losing data on every redeploy |
|
|
| **which values are secret, and which are merely settings?** | a secret goes in `own-secrets` or a grant; a setting goes in the manifest and may be overridden per node |
|
|
|
|
## The steps
|
|
|
|
1. **Name what it provides and requires**, if anything. A name is what a consumer is coupled to,
|
|
not the role it plays ([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)):
|
|
`postgres-database`, not `database`. Most modules provide nothing and require nothing — an
|
|
application is usually a leaf.
|
|
|
|
2. **Declare capabilities, not dependencies, for facts about the machine.** `container-runtime`,
|
|
`package-manager`, `seat`. A capability is detected and refused against; it is not something a
|
|
module can install.
|
|
|
|
3. **Write the resources in the order they must happen.** They are applied in the order written
|
|
and orphans are removed in reverse, so a `network` is written before the containers that join
|
|
it and removed after them.
|
|
|
|
4. **Put every secret in a file, never in `env`.** A declaration travels over the broker in plain
|
|
text: a password in `env` is a password the broker sees. **The mesh delivers parts; a module
|
|
that needs them combined combines them.**
|
|
|
|
**A sealed file holds the password and nothing else** — no key, no `=`, no newline that means
|
|
anything. So `env-file` must never point at one. It points at a file the module *declares*,
|
|
whose content leaves a hole:
|
|
|
|
```
|
|
own-secrets superuser → /var/lib/postgres/superuser.secret the password, alone
|
|
a file /var/lib/postgres/superuser.env, mode 0600,
|
|
content: POSTGRES_PASSWORD=${secret:superuser}
|
|
the container env-file: [/var/lib/postgres/superuser.env]
|
|
```
|
|
|
|
The host fills the hole on the machine, which is the only place both halves exist — the mesh
|
|
discarded the value
|
|
([credentials and their rotation](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)).
|
|
A **provisioner** is the exception: it reads a password file, so it mounts the `.secret`
|
|
directly.
|
|
|
|
Every example module in `mesh-controller` had this wrong and shipped: `own-secrets` pointing at a
|
|
path *named* `.env`, mounted as `env-file`, holding a bare password. Docker reads that as a
|
|
malformed line and the container starts **with no password set at all** — not a failure to
|
|
start, a service running on the wrong credential. They parsed and they resolved. Two tests in
|
|
`examples/modules` now refuse both halves of it.
|
|
|
|
Add `restart-on` naming the env file, or the container keeps the credential it started with
|
|
through every rotation.
|
|
|
|
5. **Pin every image by digest.** A tag moves. The manifest in a repository names artifacts; the
|
|
manifest the mesh holds names digests, and they are not the same document.
|
|
|
|
6. **Decide generate or accept.** A new module's credential is generated. **An adopted one keeps
|
|
the credential it already has** — `secret accept` — because minting a new password for a
|
|
database that already exists locks the application out of its own data.
|
|
|
|
7. **Add a provisioner only if the software cannot read a file.** A proxy that watches a
|
|
directory needs nothing. PostgreSQL needs `CREATE ROLE`, an object store needs a bucket and a
|
|
policy, an identity provider needs a realm and a client — those need a small program beside
|
|
them. It reads what the mesh granted and reconciles; it does not decide anything.
|
|
|
|
8. **Prove it in the lab, against the real software.** Not that a container started — that the
|
|
thing works: the credential authenticates, a wrong one is refused, the containers reach each
|
|
other, the data survives a restart.
|
|
|
|
## What the first port actually cost
|
|
|
|
Six attempts, one real bug. Recorded because the ratio is the lesson: **the mesh was right every
|
|
time and the scaffolding was not.**
|
|
|
|
- A shape existed in the language and no host implemented it, so every declaration carrying one
|
|
was refused whole — correctly, and the host said exactly that. **Nobody was reading the host's
|
|
log.** Read it first; it is the only place that says why a machine did nothing.
|
|
- A blind find-and-replace renamed a provision in quotes and missed the same word bare.
|
|
- A command was tested only for the invocations that should fail, so it rejected every real one
|
|
and the suite stayed green.
|
|
- A test asserted on a helper rather than on the code that calls it, three separate times. **A
|
|
test that cannot fail when the behaviour is deleted is not defending the behaviour.**
|
|
|
|
## Rules
|
|
|
|
- **Read the host's log before theorising.** A declaration that was sent and not applied says so
|
|
there and nowhere else.
|
|
- **A failing test is kept, not skipped.** It is the reproduction.
|
|
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
|
|
- **A data directory is never removed by the mesh** ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md)),
|
|
and that protects against the mesh only — not against a disk or a mistaken command.
|