Compare commits

..
892 changed files with 53025 additions and 6502 deletions
+4 -7
View File
@@ -23,13 +23,10 @@ and says they disagree.
## Amending the to-be layer ## Amending the to-be layer
1. Propose the decision record in the knowledge base (`hq-graduate`, steps 2 and 3). If it reverses an 1. Write the decision record. If it reverses an earlier one, that record gets
earlier one, it `supersedes` it, or `changes` the clauses it replaces — the earlier record's text is `status: superseded` and `superseded-by:` — its text is never edited.
never edited. 2. Edit the design; set `updated:` to today.
2. Edit the design, add `"ADR NNNN"` to its `decisions:`, and set `updated:` to today. 3. If this came from an issue, set that issue's `amended-design:`.
3. If this came from an issue, give the issue the document in the issue tracker: `amended-design` on its
`issue-tracker.move` to `resolved`, or `issue-tracker.link` with kind `design` before then. Never in the
report's frontmatter (ADR 0275).
## Amending the as-is layer ## Amending the as-is layer
+1 -1
View File
@@ -18,7 +18,7 @@ Record it as **one memory file** in Claude's persistent memory directory for thi
path is given in the session's memory instructions), with `metadata.type: project`, plus a path is given in the session's memory instructions), with `metadata.type: project`, plus a
one-line pointer in `MEMORY.md`. one-line pointer in `MEMORY.md`.
**Not** in `04-ISSUES`, the knowledge base, or anywhere else in the repository: **Not** in `04-ISSUES`, `01-RESEARCH` or anywhere else in the repository:
- A parked thought is not an issue or a research effort. Giving it a number asserts it has been - A parked thought is not an issue or a research effort. Giving it a number asserts it has been
triaged, which is exactly what deferring says has not happened. triaged, which is exactly what deferring says has not happened.
+8 -13
View File
@@ -23,19 +23,14 @@ result is not "nothing to learn" — it is the reason the next person repeats th
## Steps ## Steps
1. Read the issue: `mesh_call issue-tracker.get` with its number, and `00-report.md` when it has a 1. Read `00-report.md`. Move `status:` to `diagnosing`.
folder. Before you start, move it to `diagnosing` with `issue-tracker.move` and `by`, your name and node as 2. Investigate. Write `01-diagnosis.md` in the same folder: the trail, **dated**, including
who@node (such as `fix-399@laptop`). The tracker refuses it while another worker is current, naming them. what was ruled out and how. Archaeology — which commit, which pull request, which date a
2. Investigate. The trail goes in `issue-tracker.note`, **dated** in its text, including what was behaviour changed — is the most valuable content here.
ruled out and how; for an issue with a folder, in `01-diagnosis.md` there as well. Archaeology — 3. When the owner is known, set `status: located` and fill `located-in:` with repositories or
which commit, which pull request, which date a behaviour changed — is the most valuable content. modules from [`00-META/repos.md`](../../../00-META/repos.md).
3. When the owner is known, `issue-tracker.move` to `located` with `located-in`: entries of the 4. On resolution: `status: resolved`, fill `fixed-by:`. If the root cause was a design gap, run
tracker's registered list of owners (repositories, and modules as `mesh-catalog modules/<module>`); `hq-graduate` for the amendment and fill `amended-design:`.
the tracker refuses an owner it does not know.
4. Before you start the fix: `issue-tracker.move` to `fixing` with `by`.
5. On resolution: `issue-tracker.move` to `resolved`, taken only from `fixing`, with `fixed-by`, and `replay` or `replay-none`
for a core issue. If the root cause was a design gap, run `hq-graduate` for the amendment and give
`amended-design`. Nothing of this is written in the report's frontmatter.
## Rules ## Rules
+11 -21
View File
@@ -8,34 +8,25 @@ description: Use when a research effort in HQ concludes and becomes design, or w
Closes a research effort into a decision and a design, or amends an existing design. Closes a research effort into a decision and a design, or amends an existing design.
**Authoritative playbook:** **Authoritative playbook:**
[`00-META/process/02-graduation.md`](../../../00-META/process/02-graduation.md) — read [`00-META/process/02-graduation.md`](../../../00-META/process/02-graduation.md) — read
it; this skill adds only the scaffolding. Decision records and research efforts live whole in the it; this skill adds only the scaffolding.
knowledge base (seat `knowledge-base`, ADR 0290), reached through the mesh MCP server; nothing of them is
written into this repository.
## Steps ## Steps
1. **Check against GENESIS** — `mission.md`, `context.md`, `effect.md`. If the conclusion 1. **Check against GENESIS** — `mission.md`, `context.md`, `effect.md`. If the conclusion
conflicts, say which is wrong, in writing, before proceeding. conflicts, say which is wrong, in writing, before proceeding.
2. **Reserve the record's number.** `mesh_call knowledge-base.reserve {"kind": "decision", "for": "<title>"}`. 2. **Write the decision record** in `02-DECISIONS/`, next free number, frontmatter per
3. **Propose the decision record.** `mesh_call knowledge-base.propose` with the reserved `number`, `title`, [`02-DECISIONS/README.md`](../../../02-DECISIONS/README.md). `reconstructed: false` — this one is being taken,
`topic` (one of the six in [`02-DECISIONS/README.md`](../../../02-DECISIONS/README.md)), `date`, not recovered. Record the rejected options.
`deciders`, `clauses` (each `"**Lead.** the statement"`), `sections` (Context, Considered Options, 3. **Write the design** under `03-DESIGN/01-to-be/` with `layer: to-be`, `status: designed`,
Consequences, How it is checked, References), `links` (`extends`, `rests-on`, `supersedes`, `changes` with empty `code:`, today's `updated:`, and `decisions:` pointing at the record.
`"NNNN §N"`, `cites`), `homes` (the design's path), and `needs-operator: true` when the operator must 4. **Close the effort** — the research `00-overview.md` gets `status: graduated` and `became:`
approve it. A refusal names every rule broken: fix them all and propose again. The knowledge base pointing at both.
reviews it itself; on `changes` revise and propose again with `why`. `knowledge-base.get` shows its
status, reviews and the operator's ask.
4. **Write the design** under `03-DESIGN/01-to-be/` with `layer: to-be`, `status: designed`,
empty `code:`, today's `updated:`, and `decisions:` listing `"ADR NNNN"`.
5. **Close the effort** — `knowledge-base.propose` the effort again, whole, with `status: graduated` and
`became` links to the record (`"NNNN"`) and the design (its path).
## Amending an existing design ## Amending an existing design
A design changes only through a decision. Propose the record first; if it reverses an earlier one, A design changes only through a decision. Write the record first; if it reverses an earlier
it `supersedes` it (or `changes` the clauses it replaces) — **never edit the earlier record's text**; one, set that record's `status: superseded` and `superseded-by:` — **never edit its text**.
a correction of fact is a progressive insight (`propose` the accepted record with `insight`). Then edit Then edit the design and set `updated:`.
the design, add `"ADR NNNN"` to its `decisions:`, and set `updated:`.
## When something ships ## When something ships
@@ -51,4 +42,3 @@ cannot be checked, it is `in-progress`.
- Do not move a to-be document into `00-as-is/`. - Do not move a to-be document into `00-as-is/`.
- Do not edit an as-is document to describe an intention. - Do not edit an as-is document to describe an intention.
- Do not change a decision record's meaning. Supersede it. - Do not change a decision record's meaning. Supersede it.
- Do not write a decision record, or a copy of one, into this repository.
+2 -2
View File
@@ -10,8 +10,8 @@ Hands a settled design to a code repository. **Authoritative playbook:**
## Steps ## Steps
1. **Confirm it is settled.** `status: designed`, and every claim traceable to a decision record in the knowledge base 1. **Confirm it is settled.** `status: designed`, and every claim traceable to a record in
(`knowledge-base.get` for each entry of its `decisions:`). An open question in the text is a reason to run `hq-new-research`, not to build `02-DECISIONS/`. An open question in the text is a reason to run `hq-new-research`, not to build
around it. around it.
2. **Name the owner.** Set `code:` from 2. **Name the owner.** Set `code:` from
[`00-META/repos.md`](../../../00-META/repos.md). If the repository does not exist yet, [`00-META/repos.md`](../../../00-META/repos.md). If the repository does not exist yet,
+9 -28
View File
@@ -21,46 +21,27 @@ Opens a numbered issue. **Authoritative playbook:**
there, this is not an issue — it is a lookup. If the answer is a general lesson, it belongs in there, this is not an issue — it is a lookup. If the answer is a general lesson, it belongs in
both. both.
## The number comes from the issue tracker
Every issue is opened in the issue tracker (seat `issue-tracker`, module `mesh-issues`, ADR 0275). **Never take a
number by reading `main` or the open pull requests** — that is how numbers collided. If the tracker cannot be
reached, take no number and say so.
Decide the shape first:
- **Design or governance issue** — its narrative belongs in hq: it gets a folder `04-ISSUES/NNN-short-name/`.
- **Anything else** — it lives wholly in the tracker, and nothing is written in hq.
## Steps ## Steps
1. Load `mesh_call` and `mesh_describe` (deferred tools of the `mesh` MCP server) and open the issue: 1. Next free number. Create `04-ISSUES/NNN-short-name/00-report.md` with:
`mesh_call issue-tracker.open` with `title` (the symptom in one line), `report` (what was seen, what was expected,
how it was found — the evidence: what was run, what came back, when), optionally `located-in` when the owner is
already known, and — **only for an issue with a folder** — `record: short-name` (lower-case words joined by
hyphens, no number). The answer carries the number `NNN`.
2. **Issue wholly in the tracker:** done. Its trail goes in `issue-tracker.note`, its status changes through
`issue-tracker.move`, its links through `issue-tracker.link`.
3. **Issue with a folder:** on a branch off `origin/main`, create `04-ISSUES/NNN-short-name/00-report.md`, `NNN` being
exactly the tracker's number and `short-name` exactly the `record` passed:
```yaml ```yaml
--- ---
status: open
opened: YYYY-MM-DD opened: YYYY-MM-DD
located-in: []
fixed-by:
amended-design:
--- ---
``` ```
then `# NNN. The title`, the title given to the tracker. **No status key**: the issue's status, owner and fix 2. Write the symptom **as observed**, in plain terms, with the evidence that it happened —
live in the tracker (ADR 0275), and `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, what was run, what came back, when.
`amended-design:`, `replay:` or `replay-none:` in any report. Notes and links go to the 3. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
tracker.
4. Write the symptom **as observed**, in plain terms, with the evidence that it happened.
5. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
base entry. base entry.
6. End with open questions rather than a proposed fix. Diagnosis is a separate step. 4. End with open questions rather than a proposed fix. Diagnosis is a separate step.
## Do not ## Do not
- Do not name nodes, domains, addresses or paths. This repository is public. - Do not name nodes, domains, addresses or paths. This repository is public.
- Do not guess the owner — `located-in:` is filled by diagnosis, not by opening. - Do not guess the owner — `located-in:` is filled by diagnosis, not by opening.
- Do not renumber a folder or reuse a number: a number never moves.
+21 -20
View File
@@ -5,37 +5,38 @@ description: Use when starting a new research effort in HQ — an idea, technolo
# hq-new-research # hq-new-research
Starts a new research effort in the knowledge base. **Authoritative playbook:** Scaffolds a new research effort. **Authoritative playbook:**
[`00-META/process/01-research.md`](../../../00-META/process/01-research.md) — read it; [`00-META/process/01-research.md`](../../../00-META/process/01-research.md) — read it;
this skill only does the mechanical setup. A research effort lives whole in the knowledge base (seat this skill only does the mechanical setup.
`knowledge-base`, ADR 0290); nothing of it is written into this repository.
## Steps ## Steps
All through the mesh MCP server (`mesh_describe` gives each verb's arguments). 1. Find the next free number: list `01-RESEARCH/NNN-*`, take the highest plus one, zero-padded
to three digits.
2. Create `01-RESEARCH/NNN-descriptive-name/` (kebab-case from the topic).
3. Create `00-overview.md` in it with exactly this frontmatter, then a prose summary of **what** is
being investigated, **why**, and **what it touches**:
1. **Read what is known.** `mesh_call knowledge-base.ask {"question": "<the question in plain words>"}`; ```yaml
read an effort or record it names with `knowledge-base.get` (`kind: research` for an effort, ---
`reasoning: true` for its documents). status: active
2. **Reserve the number.** `mesh_call knowledge-base.reserve {"kind": "research", "for": "<title>"}`. The initiated: YYYY-MM-DD
number is spent; never pick one by hand. touches: []
3. **Propose the effort.** `mesh_call knowledge-base.propose` with `kind: research`, the reserved `number`, became: []
`title`, `status: active`, `date` (today), `sections` (the documents in order, each `name` and `text`; ---
the first `00-overview.md`: **what** is investigated, **why**, and **what it touches**), and `links` ```
of kind `touches` for every record, effort, issue or design it touches (`cites` for one it only names).
4. **Do the research** in further documents of the same effort. Each change is `propose` again with the 4. Do the research in further documents in the same folder. Keep the summary current.
effort whole and `why`. A refusal names every rule broken: fix them all and propose again. On review
`changes`, revise and propose again; `knowledge-base.get` shows the reviews.
## Do not ## Do not
- Do not restate the status in prose. It is the effort's `status`, in one place. - Do not restate the status in prose. It lives in frontmatter, in one place.
- Do not give a `became` link while the effort is open — `hq-graduate` gives it at closure. - Do not fill `became:` while the effort is open — `hq-graduate` sets it at closure.
- Do not skip or reuse a sequence number.
- Do not write into `03-DESIGN` from an open effort. - Do not write into `03-DESIGN` from an open effort.
- Do not write the effort, or a copy of it, into this repository.
- Do not name the mesh being observed. Evidence is required; identification is forbidden. - Do not name the mesh being observed. Evidence is required; identification is forbidden.
## Closing ## Closing
An effort never just stops. It closes through `hq-graduate` as `graduated` or `abandoned`, An effort never just stops. It closes through `hq-graduate` as `graduated` or `abandoned`,
always with a `became` link to what it turned into. Nothing is deleted. always with `became:` pointing at what it turned into. Nothing is deleted.
+17 -19
View File
@@ -5,7 +5,7 @@ description: Use when you need a cross-cutting view of where HQ stands — resea
# hq-status # hq-status
Generates a cross-cutting view **from frontmatter, the knowledge base and the issue tracker**. This is a read-and-render skill, not a Generates a cross-cutting view **from frontmatter**. This is a read-and-render skill, not a
workflow — HQ has **no central status file by design** (decision 35). Every view is workflow — HQ has **no central status file by design** (decision 35). Every view is
generated on demand and never written back to disk. generated on demand and never written back to disk.
@@ -13,29 +13,28 @@ generated on demand and never written back to disk.
| Section | Files | Frontmatter | | Section | Files | Frontmatter |
|---|---|---| |---|---|---|
| Research | the knowledge base, not a file: `knowledge-base.ask`, and `knowledge-base.get` with `kind: research` and a number | `status` (`active` / `graduated` / `abandoned`), `date`, links (`became`, `touches`, `cites`) | | Research | `01-RESEARCH/NNN-*/00-overview.md` | `status` (`active` / `graduated` / `abandoned`), `initiated`, `touches`, `became` |
| Design | `03-DESIGN/**/*.md` (not READMEs) | `layer` (`as-is` / `to-be`), `status` (`designed` / `in-progress` / `implemented` / `abandoned`), `code`, `updated`, `decisions` | | Design | `03-DESIGN/**/*.md` (not READMEs) | `layer` (`as-is` / `to-be`), `status` (`designed` / `in-progress` / `implemented` / `abandoned`), `code`, `updated`, `decisions` |
| Decisions | the knowledge base, not a file: `knowledge-base.holds` with a topic (the clauses in force, with their records), `knowledge-base.get` with a number, `knowledge-base.links` | `status` (`proposed` / `accepted` / `superseded`), `topic`, `date`, `deciders`, each clause's status, links both ways, what changed it since | | Decisions | `02-DECISIONS/NNNN-*.md` | `status` (`proposed` / `accepted` / `superseded`), `date`, `deciders`, `reconstructed`, `superseded-by`, `extends` |
| Issues | the issue tracker, not a file: `mesh_call issue-tracker.list` (optionally `status`, `owner`, `phrase`) and `issue-tracker.get` | `status` (`open` / `diagnosing` / `located` / `fixing` / `resolved` / `wontfix`), `worker` and `stale` while `diagnosing` or `fixing`, `opened`, `located-in`, `fixed-by`, `amended-design`, `record` (the folder here, when it has one) | | Issues | `04-ISSUES/NNN-*/00-report.md` | `status` (`open` / `diagnosing` / `located` / `resolved` / `wontfix`), `opened`, `located-in`, `fixed-by`, `amended-design` |
## Steps ## Steps
1. Read the designs' frontmatter (a grep across `03-DESIGN/` is enough, no need to load bodies), the 1. Read the frontmatter block from every file above — a grep across each tree is enough, no
issues from the tracker, and the records from the knowledge base. An issue report carries no status; need to load bodies.
where an issue stands is only in the tracker (ADR 0275). A decision record's or research effort's 2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** is one
status is only in the knowledge base (ADR 0290). of these views, and it is the only place the list of records is shown
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** by topic is ([ADR 0248](../../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)):
`knowledge-base.holds` with each topic of the reading order `02-DECISIONS/README.md` writes, in that run `python3 00-META/checks/index.py --print` for the records grouped by topic in the reading
order, with a `bound` large enough for the topic (it says what it cut); it is the only place the list is order `02-DECISIONS/README.md` writes, each with its status when not `accepted`. Asked for it by
shown (ADR 0248). The knowledge base has no verb that lists every record by number: a view by number number instead, render records ordered by number, with title, date and status, and reconstructed
is built from `get` per number, and says which numbers it read. ones marked. Off this checkout, the records module's `records_decisions` answers the same list.
3. Flag anything inconsistent at the end, as flags — do not silently correct the render: 3. Flag anything inconsistent at the end, as flags — do not silently correct the render:
- a `graduated` or `abandoned` effort with no `became` link - a `graduated` or `abandoned` effort with an empty `became:`
- an `implemented` design with an empty `code:` - an `implemented` design with an empty `code:`
- a `superseded` record nothing `supersedes` - a `superseded` record with no `superseded-by:`
- an `open` or `diagnosing` issue with no activity - an `open` or `diagnosing` issue with no activity
- a to-be design whose `decisions:` names a record `knowledge-base.get` does not find, or one that is - a to-be design whose `decisions:` points at a record that does not exist
not `accepted`
- an as-is design whose claims are older than its `updated:` date suggests - an as-is design whose claims are older than its `updated:` date suggests
## Do not ## Do not
@@ -45,5 +44,4 @@ generated on demand and never written back to disk.
already drifted after a single addition, which is why it was removed, and the generated one already drifted after a single addition, which is why it was removed, and the generated one
written into `02-DECISIONS/README.md` made every two open decisions conflict (issue 297), which written into `02-DECISIONS/README.md` made every two open decisions conflict (issue 297), which
is why that went too. `index.py` fails if the list is stored there again. is why that went too. `index.py` fails if the list is stored there again.
- Do not infer status from prose. Trust only a design's frontmatter, the tracker for an issue and the - Do not infer status from prose. Trust only the frontmatter; if it is wrong, flag it.
knowledge base for a record; if one is wrong, flag it.
+1 -3
View File
@@ -27,9 +27,7 @@ the claim "HQ is the source" is checked.
3. Publish it to the knowledge base under the constitution slug, replacing the body. 3. Publish it to the knowledge base under the constitution slug, replacing the body.
4. **Read it back and verify the change is present.** A publish that reported success and did 4. **Read it back and verify the change is present.** A publish that reported success and did
nothing is exactly the failure class this repository exists to name — do not skip this. nothing is exactly the failure class this repository exists to name — do not skip this.
5. Note the sync in the decision record's Consequences (in the knowledge base: while the record is 5. Note the sync in the decision record's Consequences.
proposed, `knowledge-base.propose` it again with the note and `why`; once it is accepted, `propose` it with
`insight`, saying what stood before).
## Do not ## Do not
+3 -3
View File
@@ -35,7 +35,7 @@ comparing it against the code rather than by anyone noticing:
- It described the pipeline as having a separate builder process and a build stage that - It described the pipeline as having a separate builder process and a build stage that
packages. Neither was true after 2026-08-04; the documents stayed stale until 2026-08-06 packages. Neither was true after 2026-08-04; the documents stayed stale until 2026-08-06
(ADR 0010). ([ADR 0010](../02-DECISIONS/0010-delivery.md)).
- It listed the mesh as spanning a fixed number of named machines, which is exactly the - It listed the mesh as spanning a fixed number of named machines, which is exactly the
content this repository cannot carry. content this repository cannot carry.
@@ -45,10 +45,10 @@ symlinks at all — the rule is not merely "only the installer may link", and a
elevating linking to a principle points the opposite way from where this is going. elevating linking to a principle points the opposite way from where this is going.
What exists today is that the installer owns and reconciles every link What exists today is that the installer owns and reconciles every link
(ADR 0012) — an as-is fact, recorded in ([ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md)) — an as-is fact, recorded in
[`03-DESIGN/00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md). [`03-DESIGN/00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md).
Centralising who may link narrowed the incident class; it did not close it. The intent is to Centralising who may link narrowed the incident class; it did not close it. The intent is to
remove the mechanism, recorded as ADR 0012. remove the mechanism, recorded as [ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md).
A founding document contradicting the direction of travel is precisely the failure this folder A founding document contradicting the direction of travel is precisely the failure this folder
exists to prevent. exists to prevent.
+37 -68
View File
@@ -1,12 +1,11 @@
# Checks # Checks
``` ```
python3 00-META/checks/records.py structure: links, no record written here, citations in the form "ADR NNNN" python3 00-META/checks/records.py structure: links, citations, supersession, topics
python3 00-META/checks/index.py the reading order's topics are written, and no list of records is stored python3 00-META/checks/index.py every record's topic is in the reading order, and no list of records is stored
python3 00-META/checks/cycle.py the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter python3 00-META/checks/index.py --print the records, by topic, in reading order (generated, never written)
python3 00-META/checks/words.py the glossary's retired words are not used, no daemon's line is written, and no word is defined twice python3 00-META/checks/words.py the glossary's retired words are not used, and no word is defined twice
python3 00-META/checks/words.py --list tools the words the catalogue's copy must list python3 00-META/checks/words.py --list tools the words the catalogue's copy must list
python3 00-META/checks/words.py --list lines the daemons' lines the catalogue's copy must list
``` ```
Non-zero exit on any problem, so it can be a gate rather than a report. Non-zero exit on any problem, so it can be a gate rather than a report.
@@ -23,49 +22,29 @@ indistinguishable from one that cannot.
| Check | Asserts | Found | | Check | Asserts | Found |
|---|---|---| |---|---|---|
| `links` | every relative link resolves | — (run ad hoc during authoring; now permanent) | | `links` | every relative link resolves | — (run ad hoc during authoring; now permanent) |
| `no-records` | `02-DECISIONS/` and `01-RESEARCH/` hold their README and nothing else: a decision record or research effort lives in the knowledge base (ADR 0290 rule 9) | written with the change that removed the records (issue 426); it failed on a record file put back in `02-DECISIONS/` before it passed | | `rests-on` | `decisions:` and `extends:` name records that exist and are **accepted** | the class behind both incidents |
| `decisions-form` | every entry of a design's `decisions:` is "ADR NNNN", four digits | written with issue 426; it failed on an entry `ADR 290` before it passed | | `live-citation` | a governing document citing a **superseded** record names its replacement in the same paragraph | `01-to-be/README.md` citing ADR 0022 as live guidance |
| `mention-form` | every "ADR N" in the text has four digits, and every "research N" three; a code span is an example and is not read | written with issue 426; it failed on `ADR 12` and `research 45` before it passed | | `supersession` | if A says it was superseded by B, B says it supersedes A | ADR 0012 never declared that it superseded 0011 |
| *(index.py)* | the reading order's six topics are written in `02-DECISIONS/README.md`, none twice, and the README stores no list of records (ADR 0248). The knowledge base reads these lines and refuses a record whose topic is not one of them | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed | | `numbering` | the number in the filename is the number in the heading | — |
| `topics` | every record names a topic of the reading order in `02-DECISIONS/README.md` | — |
| *(index.py)* | the reading order is written, every record's topic is one of it, and the README stores no list of records ([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)) | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code | | `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
## Where the rules on records went
The decision records and research efforts left this repository for the knowledge base (ADR 0290, issue 426),
and the rules these checks held over them left with them. The knowledge base holds each as a rule of its own,
named in its refusal: `knowledge-base.propose` refuses a record that breaks one of the rules that judge one
record, naming every reason at once, and the four rules that judge the whole record set run after every
acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, one table-test case per rule).
| Rule here until issue 426 | Where it lives now |
|---|---|
| `records.py` `rests-on`, for a record's `extends:` | the knowledge base's `rests-on` at propose, and the whole-set rule `rests-on-live` |
| `records.py` `rests-on`, for a design's `decisions:` naming an accepted record | **nowhere yet**: this repository cannot read the knowledge base. The reviewer checks each new entry with `knowledge-base.get`. Designs become the knowledge base's own kind under issue 428 |
| `records.py` `live-citation`, in a record | the knowledge base's `live-citation` |
| `records.py` `live-citation`, in `00-META/` and `03-DESIGN/01-to-be/` | **nowhere yet**, as above: the reviewer checks a cited record's status with `knowledge-base.get`; issue 428 |
| `records.py` `supersession` | the knowledge base's `supersedes-accepted` and `self-link`, and the whole-set rule `supersession` |
| `records.py` `partial-supersession` | the knowledge base's `changes` link to a clause, `changes-in-force` and `supersedes-accepted`, and the whole-set rule `partial` |
| `records.py` `clauses` | the knowledge base's `has-clause`, `clause-numbering`, `clause-lead` and `clause-size` |
| `records.py` `numbering` | the knowledge base's `title` and `reserved` |
| `records.py` `topics`, `index.py` (a record's topic) | the knowledge base's `topic`, against the reading order `index.py` still checks |
| `records.py` `insights` | the knowledge base's `insight-form`; an accepted record changes by a new version marked as a progressive insight |
| `index.py --print` | `knowledge-base.holds` with a topic |
| `cycle.py` research: a known status, `became:` when graduated | the knowledge base's `research-status`, `overview` and `became` |
| `cycle.py` no two decision records share a number | the knowledge base's `reserved`, and the issue tracker's counter behind `knowledge-base.reserve` |
| `cycle.py` an accepted decision cited by nothing | the knowledge base's whole-set rule `cited`: a record accepted there is cited by another record or names a home (`homes` at propose) |
| `cycle.py` a deferral names the issue that tracks it | the knowledge base's `deferral` |
| `words.py` over research efforts from 2026-10-07, and `kept` in `words-allowed.md` | the knowledge base's `retired-word` and `daemon-line`, from this glossary |
## What is deliberately not checked ## What is deliberately not checked
- **Whether a record a document cites exists, or is still accepted.** A checkout cannot read the knowledge - **`02-DECISIONS/` and `01-RESEARCH/` may cite superseded records freely.** A decision record
base, and a check that called the mesh would make every merge here depend on it. Until designs become the discusses history; research records what was observed. Flagging those would produce noise on
knowledge base's own kind (issue 428), the reviewer asks `knowledge-base.get` for each record a change correct documents, and a check that cries wolf gets suppressed — which costs more than not
cites in `00-META/` or `03-DESIGN/01-to-be/` (the documents that tell somebody what to do). An as-is having it.
document may rest on a superseded record: it describes what runs, and what runs was built under whatever - **`03-DESIGN/00-as-is/` may rest on a superseded record.** It describes what runs, and what
was decided at the time (ADR 0006: *as-is describing a superseded decision is exactly what as-is is for*). runs was built under whatever was decided at the time
- **Whether a citation's prose is still true.** A document can cite an accepted record and describe it ([ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md):
wrongly, and nothing here notices. *as-is describing a superseded decision is exactly what as-is is for*).
- **Whether a citation's prose is still true.** Only whether the record it points at is live.
A document can cite an accepted record and describe it wrongly, and nothing here notices.
So "governing" means `00-META/` and `03-DESIGN/01-to-be/` — the documents that tell somebody
what to do.
## Adding a check ## Adding a check
@@ -74,38 +53,28 @@ whose failure has never been observed is a guess about its own correctness.
## cycle.py ## cycle.py
The development cycle, checked (ADR 0080): The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
every design parses, its `layer:` matches its folder and its `status:` is known; a to-be design names a a to-be design names a decision, an in-progress/implemented design names its owning code, a
decision in `decisions:`; an in-progress or implemented design names its owning code; and no two issue located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
records share a number (issue 155 — the number is how a record is cited, and `main` lags every open pull research overview says what it became, and no two issue records share a number (issue 155 — the
request, so two people reading it allocate the same one). An issue report carries the day it was `opened:` number is how a record is cited, and `main` lags every open pull request, so two people reading it
and no status key — `status:`, `located-in:`, `fixed-by:`, `amended-design:`, `replay:`, `replay-none:` or allocate the same one). And a core issue — one opened from 2026-10-07 whose `located-in` names a core
`resolved:` — because an issue's status lives in the issue tracker (ADR 0275); it failed on all 361 reports repository — resolves only with `replay:` (an id in mesh-lab's replays register) or `replay-none:` saying
that carried them before they were removed. The rules on an issue's status this check held until then — an why none is possible ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md));
owner for a located issue, a fix for a resolved one, and a core issue's `replay` or `replay-none` (ADR 0237) it failed on a resolved core issue carrying neither before it passed. `python3 00-META/checks/cycle.py`
— are refused at the write by `issue-tracker.move`. Its rules on research efforts and decision records left
with them (the table above). `python3 00-META/checks/cycle.py`
## words.py ## words.py
One word per thing, checked (ADR 0244). One word per thing, checked ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
renamed* lines, and fails on a retired word or a bare identifier in running prose — what is left once code, renamed* lines, and fails on a retired word or a bare identifier in running prose — what is left once code,
quotations, struck-through text, link targets, comments and frontmatter are taken out — in `00-META/`, quotations, struck-through text, link targets, comments and frontmatter are taken out — in `00-META/`,
`03-DESIGN/`, `AGENTS.md`, `README.md`, and issues opened from 2026-10-07. Decision records and research `03-DESIGN/`, `AGENTS.md`, `README.md`, and research and issues dated from 2026-10-07. Decision records are
efforts live in the knowledge base, which holds a proposed record to the same words (the table above). It never checked. It also fails when a glossary head word heads two entries or is also retired.
also fails when a glossary head word heads two entries or is also retired. [`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date, or
[`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date. With `MESH_CATALOG_DIR` set it compares the catalogue's copy a graduated research effort kept as written. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
of the tools' retired words (`retired-words`) with the glossary. of the tools' retired words (`retired-words`) with the glossary.
It also fails on a **daemon's line** (ADR 0272,
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
for in the same documents, from 2026-10-09 for issues, in the document's own words — code blocks and
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
(`retired-lines`) with the list.
It failed on something real before it passed: 581 uses of retired words in 63 documents on its first run, besides the glossary itself and research 034 — It failed on something real before it passed: 581 uses of retired words in 63 documents on its first run, besides the glossary itself and research 034 —
"the host" for the node-engine in 30 designs, "control plane" in 10, the glossary's own entry for the tool runner — "the host" for the node-engine in 30 designs, "control plane" in 10, the glossary's own entry for the tool runner —
and two glossary contradictions (the "console", the deprecated broker's seat), fixed in the change that and two glossary contradictions (the "console", the deprecated broker's seat), fixed in the change that
+111 -33
View File
@@ -4,7 +4,7 @@
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research -> The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules, decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
frontmatter, because a status this repository holds lives in frontmatter and nowhere else. frontmatter, because status lives in frontmatter and nowhere else.
What is enforced: What is enforced:
@@ -12,25 +12,25 @@ What is enforced:
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
design without a decision -- and once `in-progress` or `implemented` it names design without a decision -- and once `in-progress` or `implemented` it names
its owning code (`code:`) -- no development without a design that says where. its owning code (`code:`) -- no development without a design that says where.
issues every report has frontmatter, and no two records share a number -- the number is issues a known `status:`; once `located`, `located-in:` names the owner;
how a record is cited. An issue's status, owner, fix and replay live in the issue once `resolved`, `fixed-by:` says what fixed it (prose counts --
tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move` "nothing, the capability existed" is an answer). And no two records share a
refuses at the write what this check refused at merge until then: a status outside number -- the number is how a record is cited.
the five, `located` without an owner, `resolved` without a fix, a core issue replays a core issue -- one opened from 2026-10-07 whose `located-in:` names a core repository --
resolved without its replay (ADR 0237), `wontfix` without a reason. A report resolves only with `replay:` (the replay's id in mesh-lab's replays register) or
carries none of those keys, only the day it was `opened:` -- the tracker holds `replay-none:` (why no replay is possible): ADR 0237, to-be 45 §9. A replay is what
them, and two places holding one fact drift. The keys the reports opened before proves a fix fails before and passes after; without one, a fixed class comes back
the import carried were removed once the import held them; the commit it read through a different door (research 031: four such chains in one week).
is on every imported issue. research a known `status:`; a `graduated` overview says what it `became:`, and every
target it names exists.
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
frontmatter, a research overview, an issue report, a 00-META document, or another
record's extends/supersedes chain. A decision nothing points at is one nobody will
find by following pointers -- which is how records go stale in people's heads.
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the Deliberately NOT enforced: `resolved` issues may leave `located-in` empty (a symptom that
rules this check held over them are the knowledge base's now: a research effort's status and what it turned out not to be a defect has no owner), and as-is docs need no decisions (they
became, and a decision's duplicate number, are refused by `knowledge-base.propose`; an accepted decision describe what exists, not what was decided).
cited by nothing is the knowledge base's whole-set rule `cited`; a deferral that names no issue is its
rule `deferral`. README.md beside this file says where each went.
Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what
was decided).
python3 00-META/checks/cycle.py python3 00-META/checks/cycle.py
""" """
@@ -43,11 +43,18 @@ import sys
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"} DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
# The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275, # The core, as to-be 45 names it, by where its code lives: the controller, the node-engine, the node
# slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report # tools and the console, the SDK's loops, and the catalogue's bus, forge (the announcer of merges) and
# carrying one of them is a status written in two places, whenever it was opened. # build agent. A `located-in:` entry naming any of these makes an issue a core issue.
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved") CORE = re.compile(r"\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|"
r"mesh-catalog\s+modules/(nats|gitea|build-agent))\b")
# The day the rule began (ADR 0237): issues opened before it are not held to it.
REPLAYS_FROM = "2026-10-07"
REPLAY_ID = re.compile(r"^R\d+\b")
def rel(path): def rel(path):
@@ -139,27 +146,98 @@ def main():
else: else:
seen[number] = name seen[number] = name
# Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue # And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
# tracker, and its `move` refuses at the write what this check refused at merge. A report carries none # on main from two sessions within the hour, and every check passed.
# of those keys. seen_records = {}
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
name = os.path.basename(path)
number = name.split("-", 1)[0]
if not number.isdigit():
continue
if number in seen_records:
bad(os.path.join("02-DECISIONS", name),
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
"free number across main AND every open pull request; the branch that lands last "
"renumbers" % (number, seen_records[number]))
else:
seen_records[number] = name
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))): for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
front = frontmatter(path) front = frontmatter(path)
if front is None: if front is None:
bad(path, "no frontmatter") bad(path, "no frontmatter")
continue continue
status = front.get("status")
if status not in ISSUE_STATUSES:
bad(path, "status %r is not one of %s" % (status, sorted(ISSUE_STATUSES)))
if status in ("located", "resolved") and not listy(front, "located-in"):
bad(path, "status %s but located-in is empty" % status)
if status == "resolved" and not listy(front, "fixed-by"):
bad(path, "status %s but fixed-by says nothing" % status)
# A core issue resolves with its replay, or with why none is possible (ADR 0237).
replay = " ".join(listy(front, "replay"))
if replay and not REPLAY_ID.match(replay):
bad(path, "replay: %r names no replay -- an id from mesh-lab's replays register, R<issue>" % replay)
core = any(CORE.search(entry) for entry in listy(front, "located-in"))
opened = str(front.get("opened") or "") opened = str(front.get("opened") or "")
if not opened: if (status == "resolved" and core and opened >= REPLAYS_FROM and not replay
bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD") and not " ".join(listy(front, "replay-none")).strip()):
bad(path, "a core issue resolved with no replay: name it in `replay:` (mesh-lab replays "
"register.go) or say in `replay-none:` why none is possible (ADR 0237)")
# ---- research ----------------------------------------------------------------------
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
front = frontmatter(path)
if front is None:
bad(path, "no frontmatter")
continue continue
carried = [key for key in TRACKER_KEYS if key in front] status = front.get("status")
if carried: if status not in RESEARCH_STATUSES:
bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker " bad(path, "status %r is not one of %s" % (status, sorted(RESEARCH_STATUSES)))
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here" if status == "graduated":
% ", ".join("`%s:`" % key for key in carried)) became = listy(front, "became")
if not became:
bad(path, "graduated but became: names nothing")
for target in became:
if not os.path.exists(os.path.join(ROOT, target)):
bad(path, "became names %s, which does not exist" % target)
# ---- decisions -------------------------------------------------------------------
records = {}
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
front = frontmatter(path)
records[os.path.basename(path)] = (path, (front or {}).get("status"))
cited = set()
sources = (glob.glob(os.path.join(ROOT, "03-DESIGN", "*", "*.md"))
+ glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))
+ glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))
+ glob.glob(os.path.join(ROOT, "00-META", "**", "*.md"), recursive=True))
for path in sources:
text = open(path, encoding="utf-8").read()
if os.sep + "03-DESIGN" + os.sep in path:
# A design doc's governing citations live in frontmatter; a prose mention is
# commentary, not a home.
m = re.match(r"^---\n(.*?)\n---", text, re.S)
text = m.group(1) if m else ""
for m in re.finditer(r"([0-9]{4}-[^\s\)\],#]+\.md)", text):
cited.add(os.path.basename(m.group(1)))
for name in records:
front = frontmatter(records[name][0]) or {}
for key in ("extends", "supersedes", "superseded-by"):
v = front.get(key)
if isinstance(v, str) and v:
cited.add(os.path.basename(v))
for name, (path, status) in records.items():
if status == "accepted" and name not in cited:
bad(path, "an accepted decision nothing in the cycle cites -- give it a home in a "
"design doc's decisions:, a research became:, an issue, or 00-META")
checked = ( checked = (
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md"))) len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))) + len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
+ len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md")))
+ len(records)
) )
if failures: if failures:
print("cycle: %d document(s) break the development cycle:" % len(failures)) print("cycle: %d document(s) break the development cycle:" % len(failures))
+64 -23
View File
@@ -1,21 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""The reading order of the decision records: its topics are written here, its records are not. """The reading order of the decision records: its topics are written, its list is generated.
A number identifies a record and never changes, so the reading order is not the order of the numbers. A number identifies a record and never changes, so the folder listing is creation order rather
It is the six topics `02-DECISIONS/README.md` writes down, in order, and each record's own topic. The than reading order. The reading order is the six topics `02-DECISIONS/README.md` writes down, in
records live in the knowledge base (ADR 0290, issue 426), which reads these lines through the records order, and each record's own `topic:`. The list of records under each topic is generated from
module and refuses a record whose topic is not one of them; the list of records under a topic is its that frontmatter when somebody reads it, and never stored (ADR 0248, hq issue 297): a stored
answer to `knowledge-base.holds` with that topic, never stored here (ADR 0248, hq issue 297). list was a line every decision pull request added to one file, so any two open at once conflicted.
python3 00-META/checks/index.py check python3 00-META/checks/index.py check
python3 00-META/checks/index.py --print print the list, by topic, in reading order
It fails when the README's reading order is missing or names a topic twice, and when the README stores It fails when the README's reading order is missing, when a record's topic is not one of its
a list of records again — the index markers, or a line of a generated list. The last one is what keeps topics, and when the README stores a list of records again — the index markers, or a line of
the conflict of issue 297 from coming back. Whether every record's topic is one of the six is the the generated list. The last one is what keeps the conflict from coming back.
knowledge base's rule `topic`, judged when a record is proposed.
""" """
import glob
import io import io
import os
import re import re
import sys import sys
@@ -23,13 +25,13 @@ README = "02-DECISIONS/README.md"
# A topic in the README's reading order, e.g. # A topic in the README's reading order, e.g.
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ... # 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
# The knowledge base (mesh-knowledge, reference.go) reads the same line, so the form is a contract. # The records module's `records_decisions` reads the same line, so the form is a contract.
TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M) TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M)
# What a stored list looks like: the old markers, or a line of the list index.py used to print. # What a stored list looks like: the old markers, or a line of the list this script prints.
STORED = [ STORED = [
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"), (re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of a generated list"), (re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of the generated list"),
] ]
@@ -40,33 +42,72 @@ def reading_order(text=None):
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)] return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
def main(): def field(text, name):
if "--print" in sys.argv: m = re.search(r'^%s:\s*(.+)$' % name, text, re.M)
print("index: the records live in the knowledge base (ADR 0290). Read a topic's with " return m.group(1).strip() if m else None
"`knowledge-base.holds` and that topic, through the mesh MCP server.")
return 2
def records():
out = []
for path in sorted(glob.glob('02-DECISIONS/0*.md')):
text = io.open(path, encoding='utf-8').read()
heading = re.search(r'^# \d+\.\s*(.+)$', text, re.M)
out.append({
"file": os.path.basename(path),
"number": os.path.basename(path)[:4],
"title": heading.group(1).strip() if heading else "(no heading)",
"topic": field(text, "topic"),
"status": field(text, "status"),
})
return out
def render(topics, rs):
lines = []
for topic, label in topics:
rows = [r for r in rs if r["topic"] == topic]
if not rows:
continue
lines.append("### %s" % label)
lines.append("")
for r in rows:
mark = "" if r["status"] == "accepted" else " *(%s)*" % r["status"]
lines.append("- **%s** — [%s](%s)%s" % (r["number"], r["title"], r["file"], mark))
lines.append("")
return "\n".join(lines)
def main():
text = io.open(README, encoding='utf-8').read() text = io.open(README, encoding='utf-8').read()
topics = reading_order(text) topics = reading_order(text)
rs = records()
if "--print" in sys.argv:
print(render(topics, rs))
return 0
problems = [] problems = []
if not topics: if not topics:
problems.append("%s writes no reading order: no line of the form " problems.append("%s writes no reading order: no line of the form "
"'1. **Label** — `topic: name`. ...'; the knowledge base refuses every " "'1. **Label** — `topic: name`. ...'" % README)
"record without it" % README)
names = [t for t, _ in topics] names = [t for t, _ in topics]
if len(set(names)) != len(names): if len(set(names)) != len(names):
problems.append("%s names a topic twice in its reading order" % README) problems.append("%s names a topic twice in its reading order" % README)
for pattern, what in STORED: for pattern, what in STORED:
if pattern.search(text): if pattern.search(text):
problems.append("%s stores a list of records (%s). The records live in the knowledge base, " problems.append("%s stores the list of records (%s). The list is generated when read "
"and a topic's list is `knowledge-base.holds` with that topic" % (README, what)) "(ADR 0248): `python3 00-META/checks/index.py --print`" % (README, what))
known = set(names)
for r in rs if known else []: # with no reading order, every record would be named; one line says why
if r["topic"] not in known:
problems.append("%s: topic %r is not one of the reading order's: %s"
% (r["file"], r["topic"], ", ".join(names)))
for p in problems: for p in problems:
print("index: " + p) print("index: " + p)
if problems: if problems:
return 1 return 1
print("index: %d topics in the reading order, no stored list" % len(topics)) print("index: %d topics, %d records, no stored list" % (len(topics), len(rs)))
return 0 return 0
+259 -68
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Structural checks over HQ's own documents. """Structural checks over HQ's own records.
Every check here exists because the thing it checks for actually happened. See README.md Every check here exists because the thing it checks for actually happened. See README.md
for which incident is behind which check. Run from the repository root: for which incident is behind which check. Run from the repository root:
@@ -7,11 +7,6 @@ for which incident is behind which check. Run from the repository root:
python3 00-META/checks/records.py python3 00-META/checks/records.py
Exits non-zero if anything fails, so it can be a gate rather than a report. Exits non-zero if anything fails, so it can be a gate rather than a report.
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
rules that judged them here are the knowledge base's now: `propose` refuses a record that breaks one.
README.md beside this file says where each rule went. What stays here judges what this repository
still holds, and keeps the records from coming back.
""" """
import os import os
@@ -20,17 +15,14 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Where a citation is *guidance* rather than history. A document here tells somebody what to
# do, so a link to a superseded record is an instruction to follow a withdrawn decision.
# 02-DECISIONS and 01-RESEARCH are deliberately absent: they record what was decided and what
# was observed, and both legitimately discuss superseded records at length.
GOVERNING = ("00-META/", "03-DESIGN/01-to-be/")
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)") LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
ADR_FILE = re.compile(r"^(\d{4})-")
# The two folders the records left. Each keeps a README that points at the knowledge base, and
# 02-DECISIONS/README.md also writes the reading order's topics, which the knowledge base reads.
RECORD_FOLDERS = ("02-DECISIONS", "01-RESEARCH")
POINTERS = {"02-DECISIONS/README.md", "01-RESEARCH/README.md"}
# A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
DECISION_ENTRY = re.compile(r"^ADR \d{4}$")
ADR_MENTION = re.compile(r"\bADRs?\s+(\d+)\b")
RESEARCH_MENTION = re.compile(r"\b[Rr]esearch\s+(\d+)\b")
def markdown_files(): def markdown_files():
@@ -53,7 +45,7 @@ def read(path):
def frontmatter(text): def frontmatter(text):
"""Minimal frontmatter reader — enough for the fields these checks use. """Minimal frontmatter reader — enough for the fields these checks use.
Not a YAML parser on purpose: a dependency in a repository that has none, to read a few Not a YAML parser on purpose: a dependency in a repository that has none, to read four
scalar fields and one list, would cost more than it returns. scalar fields and one list, would cost more than it returns.
""" """
if not text.startswith("---\n"): if not text.startswith("---\n"):
@@ -82,11 +74,24 @@ def frontmatter(text):
return fields return fields
def unquote(value): def load_records():
value = value.strip() """Every decision record, by its four-digit number."""
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": records = {}
return value[1:-1] folder = os.path.join(ROOT, "02-DECISIONS")
return value for name in sorted(os.listdir(folder)):
match = ADR_FILE.match(name)
if not name.endswith(".md") or not match:
continue
path = os.path.join(folder, name)
text = read(path)
records[match.group(1)] = {
"number": match.group(1),
"name": name,
"path": path,
"text": text,
"front": frontmatter(text),
}
return records
class Failures: class Failures:
@@ -124,62 +129,243 @@ def check_links(failures):
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}") failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
def check_no_records(failures): def check_rests_on(failures, records):
"""No decision record or research effort is written into this repository again (ADR 0290 rule 9). """A document's `decisions:` and a record's `extends:` must name a live record.
The knowledge base is the only place a record is read; a file here would be a second copy that These are the load-bearing citations: the document declares that it rests on that
drifts from it, and nothing would read it. The folders keep their pointer READMEs and nothing else. decision. Resting on a withdrawn one is the defect this whole check set exists for.
""" """
for folder in RECORD_FOLDERS: for path in markdown_files():
base = os.path.join(ROOT, folder) front = frontmatter(read(path))
if not os.path.isdir(base): cited = list(front.get("decisions", []) or [])
failures.add("no-records", folder, "the folder is gone; it keeps a README that points at the " extends = front.get("extends")
"knowledge base (and, for 02-DECISIONS, the reading order the knowledge base reads)") if isinstance(extends, str) and extends:
cited.append(extends)
for entry in cited:
match = ADR_FILE.match(os.path.basename(entry))
if not match:
failures.add("rests-on", rel(path), f"not a decision record: {entry}")
continue continue
for current, dirs, files in os.walk(base): number = match.group(1)
for name in sorted(files + [d + "/" for d in dirs]): if number not in records:
path = rel(os.path.join(current, name)) failures.add("rests-on", rel(path), f"no such record: {entry}")
if path.rstrip("/") in POINTERS: continue
status = records[number]["front"].get("status")
if status != "accepted":
# A proposed record may extend another proposed one. Decisions are drafted in
# chains -- 0059 extends 0057 while both await review -- and refusing that would
# mean either drafting out of order or marking records accepted to satisfy a
# check, which is the failure this repository already made once.
if frontmatter(read(path)).get("status") == "proposed":
continue
# An as-is document describes what runs, and what runs was built under
# whatever was decided at the time. ADR 0056: "as-is describing a superseded
# decision is exactly what as-is is for."
if rel(path).startswith("03-DESIGN/00-as-is/"):
continue
# A withdrawn record's citations are history. It instructs nobody -- every reader
# is sent to its superseder -- so what it was built on may itself be withdrawn.
# Refusing that would mean rewriting the lineage of a record whose reasoning is
# the thing the immutability rule protects.
if frontmatter(read(path)).get("status") == "superseded":
continue
# An extension that supersedes legitimately names what it replaced.
this = ADR_FILE.match(os.path.basename(path))
supersedes = records[number]["front"].get("superseded-by", "")
if this and supersedes and os.path.basename(path) in str(supersedes):
continue continue
failures.add( failures.add(
"no-records", "rests-on",
path, rel(path),
"a decision record or research effort lives in the knowledge base, never here: " f"rests on ADR {number}, which is '{status}' — a document may not rest on a "
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)", f"record that is not accepted",
) )
def check_decisions_entries(failures): def check_live_citations(failures, records):
"""A design's `decisions:` names each record as "ADR NNNN". """In a governing document, a link to a superseded record must name its replacement.
A to-be design rests on the records it names, and the knowledge base answers for a record by its The reader of a rule needs to know the rule was withdrawn, and needs somewhere to go.
number; an entry in another form is a citation nothing can follow. Naming the superseder in the same paragraph is both, and it is what a person would
write anyway.
""" """
superseded = {
number: record["front"].get("superseded-by", "")
for number, record in records.items()
if record["front"].get("status") == "superseded"
}
for path in markdown_files(): for path in markdown_files():
if not rel(path).startswith("03-DESIGN/"): if not any(rel(path).startswith(prefix) for prefix in GOVERNING):
continue continue
for entry in frontmatter(read(path)).get("decisions", []) or []:
if not DECISION_ENTRY.match(unquote(entry)):
failures.add("decisions-form", rel(path),
f"decisions: entry {entry!r} is not 'ADR NNNN' (four digits)")
def check_mentions(failures):
"""In running text, a decision record is "ADR NNNN" (four digits) and a research effort "research NNN"
(three): the form the knowledge base and the issue tracker read a citation in. A code span is an example,
and is not read."""
for path in markdown_files():
text = read(path) text = read(path)
for number, line in enumerate(text.split("\n"), 1): offset = 0
line = re.sub(r"`[^`]*`", "", line) for paragraph in text.split("\n\n"):
for m in ADR_MENTION.finditer(line): line_no = text[:offset].count("\n") + 1
if len(m.group(1)) != 4: offset += len(paragraph) + 2
failures.add("mention-form", f"{rel(path)}:{number}", targets = LINK.findall(paragraph)
f"{m.group(0)!r}: a decision record is named 'ADR NNNN', four digits") for target in targets:
for m in RESEARCH_MENTION.finditer(line): match = ADR_FILE.match(os.path.basename(target.split("#")[0]))
if len(m.group(1)) != 3: if not match or match.group(1) not in superseded:
failures.add("mention-form", f"{rel(path)}:{number}", continue
f"{m.group(0)!r}: a research effort is named 'research NNN', three digits") number = match.group(1)
replacement = os.path.basename(str(superseded[number]))
if not replacement:
failures.add(
"live-citation",
f"{rel(path)}:{line_no}",
f"cites superseded ADR {number}, which names no superseder",
)
continue
if not any(replacement in t for t in targets):
failures.add(
"live-citation",
f"{rel(path)}:{line_no}",
f"cites superseded ADR {number} without naming its replacement "
f"({replacement}) in the same paragraph",
)
def check_supersession_symmetry(failures, records):
"""If A says it was superseded by B, B must say it supersedes A."""
for number, record in records.items():
front = record["front"]
status = front.get("status")
by = os.path.basename(str(front.get("superseded-by", "")))
if status == "superseded" and not by:
failures.add("supersession", rel(record["path"]), "marked superseded but names no superseder")
continue
if by and status != "superseded":
failures.add("supersession", rel(record["path"]), f"names a superseder but status is '{status}'")
if not by:
continue
match = ADR_FILE.match(by)
if not match or match.group(1) not in records:
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
continue
other = records[match.group(1)]
# `supersedes:` may name one record or several. One decision replacing two is a real
# situation -- two records that built and refined the same wrong mechanism are withdrawn
# by the one record that removes it -- and a check that allows only one would force
# either a chain of pro-forma records or an unmarked supersession.
claimed = other["front"].get("supersedes", "")
if isinstance(claimed, str):
claimed = [claimed] if claimed else []
claims = [os.path.basename(str(entry)) for entry in claimed]
if record["name"] not in claims:
failures.add(
"supersession",
rel(other["path"]),
f"ADR {number} says this supersedes it; this record does not say so "
f"(supersedes: {', '.join(claims) or 'absent'})",
)
def check_topics(failures, records):
"""Every record names a topic of the reading order.
The topic is what puts a record in the reading order, so a record without one — or with one
nobody defined — disappears from the generated list rather than appearing in the wrong place.
That is the quiet failure, so it is the one checked. The topics are the ones
`02-DECISIONS/README.md` writes down, read the way `index.py` reads them, so there is one list.
"""
from index import reading_order
known = {topic for topic, _ in reading_order()}
for number, record in sorted(records.items()):
topic = record["front"].get("topic")
if not topic:
failures.add("topics", rel(record["path"]),
"no topic, so it has no place in the reading order")
elif topic not in known:
failures.add("topics", rel(record["path"]),
"topic %r is not one of: %s" % (topic, ", ".join(sorted(known))))
def check_numbering(failures, records):
"""The number in the filename is the number in the heading."""
for number, record in records.items():
heading = re.search(r"^# (\d+)\.", record["text"], re.M)
if not heading:
failures.add("numbering", rel(record["path"]), "no '# N. Title' heading")
elif heading.group(1) != number.lstrip("0"):
failures.add(
"numbering",
rel(record["path"]),
f"filename says {number}, heading says {heading.group(1)}",
)
def check_progressive_insights(failures, records):
"""A correction made inside a record is marked and dated, or it is a silent rewrite.
A record may be corrected in place when a *fact* in it went stale and the decision still
stands (`02-DECISIONS/README.md`, "Progressive insight"). The whole safety of that allowance
is that the correction is legible in the record rather than only in a diff nobody reads, so
the form is what is checked here: every mention of an insight is the marker, the marker
carries an ISO date, and that date is not earlier than the decision's own — an insight
predating the decision it corrects is a copied marker, not a correction.
What this cannot check is an edit made with no marker at all. Nothing mechanical can; that
one is the reviewer's, reading the diff. The check keeps the *marked* path honest so that an
unmarked change stands out as the anomaly it is.
"""
phrase = re.compile(r"progressive insight", re.I)
# Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across
# newlines will happily join an unrelated `**` far above to the marker below, reporting the
# whole span between them. It did exactly that the first time this ran.
# Trailing words after the date are allowed — "— 2026-09-26, correcting the one above." — so
# an insight can say what it relates to. Only the date's presence and position are fixed.
marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*")
loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*")
iso = re.compile(r"^\d{4}-\d{2}-\d{2}$")
for number, record in sorted(records.items()):
text = record["text"]
if not phrase.search(text):
continue
decided = str(record["front"].get("date", ""))
good = [(m.start(), m.end(), m.group(1)) for m in marker.finditer(text)]
for m in loose.finditer(text):
if any(s <= m.start() and m.end() <= e for s, e, _ in good):
continue
# A bold run carrying a link is discussing an insight — usually another record's —
# rather than marking one. A marker never needs to cite anything.
if "](" in m.group(0):
continue
failures.add("insights", rel(record["path"]),
"a progressive insight is not in the dated marked form "
"'**Progressive insight \u2014 YYYY-MM-DD.**': %s" % m.group(0))
for _, _, stamp in good:
if decided and iso.match(decided) and stamp < decided:
failures.add("insights", rel(record["path"]),
"a progressive insight dated %s predates the decision (%s)"
% (stamp, decided))
covered = [(s, e) for s, e, _ in good]
for m in phrase.finditer(text):
if any(s <= m.start() and m.end() <= e for s, e in covered):
continue
line = text.rfind("\n", 0, m.start()) + 1
end = text.find("\n", m.end())
whole = text[line:end if end != -1 else len(text)]
if whole.lstrip().startswith("#"):
continue
# A line that also carries a link is discussing the rule, not marking a correction:
# a marker never needs to cite anything, and a record that reasons about the policy
# must be able to name it. Bare prose with no citation is the informal marking this
# is here to catch.
if "](" in whole:
continue
if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)):
continue
failures.add("insights", rel(record["path"]),
"'progressive insight' appears unmarked; a correction is marked and "
"dated, or it is a silent rewrite")
def check_status_against_code(failures): def check_status_against_code(failures):
@@ -215,11 +401,16 @@ def check_status_against_code(failures):
def main(): def main():
failures = Failures() failures = Failures()
records = load_records()
check_links(failures) check_links(failures)
check_no_records(failures) check_rests_on(failures, records)
check_decisions_entries(failures) check_live_citations(failures, records)
check_mentions(failures) check_supersession_symmetry(failures, records)
check_numbering(failures, records)
check_topics(failures, records)
check_status_against_code(failures) check_status_against_code(failures)
check_progressive_insights(failures, records)
print(f"records: {len(records)} decision records checked")
return failures.report() return failures.report()
+10 -4
View File
@@ -1,10 +1,16 @@
# Words allowed — documents `words.py` does not hold to the glossary's retired words # Words allowed — documents `words.py` does not hold to the glossary's retired words
Read by [`words.py`](words.py) (ADR 0244). Read by [`words.py`](words.py) ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
themselves. A row whose document no longer uses a retired word fails too, so this list only shrinks. The themselves — or `kept` for a graduated research effort, which records what was said and keeps its words
research efforts this list kept as written (research 034 and 042) live in the knowledge base now (ADR 0290), the way a decision record does. A row whose document no longer uses a retired word fails too, so this
which keeps a record's words as it was imported. list only shrinks.
| Document | Until | Why | | Document | Until | Why |
|---|---|---| |---|---|---|
| `01-RESEARCH/034-the-mesh-in-domains/00-overview.md` | kept | the effort that studied these words; its findings quote and propose them as they stood before ADR 0244 |
| `01-RESEARCH/034-the-mesh-in-domains/01-the-concepts-in-use.md` | kept | as above: the inventory of the words in use |
| `01-RESEARCH/034-the-mesh-in-domains/02-the-domains.md` | kept | as above: the proposed domains, in the words of the day |
| `01-RESEARCH/034-the-mesh-in-domains/03-the-clashes.md` | kept | as above: every clash names the words that clashed |
| `01-RESEARCH/034-the-mesh-in-domains/04-how-the-glossary-is-checked.md` | kept | as above: the check's own examples |
| `01-RESEARCH/034-the-mesh-in-domains/05-a-glossary-by-domain.md` | kept | as above: the proposed glossary, with the entries of the day |
+34 -128
View File
@@ -28,32 +28,17 @@ What is enforced:
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs. MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; a research
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the effort initiated, or an issue opened, on or after FROM. Never 02-DECISIONS/: a decision record keeps the
controller (`mesh-controller`) are daemons, and a person never types their names — so a words it was written with.
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
module check …` is a line). A line is looked for in the document's own words — code
blocks and code spans included, since that is where a line to type is written — with
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
typed at a machine no mesh reaches yet, and the engine's usage says so.
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; an issue opened
on or after FROM (on or after LINES_FROM for the lines). The decision records and research efforts live in
the knowledge base (ADR 0290, issue 426), which holds a record proposed there to the same words through its
rules `retired-word` and `daemon-line`, read from this glossary.
A document the check fails on that cannot be reworded in the change that found it is named, with a date A document the check fails on that cannot be reworded in the change that found it is named, with a date
and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and
an entry for a document that no longer needs it fails too. an entry for a document that no longer needs it fails too. `kept` instead of a date is allowed only for a
graduated research effort, which is a record of what was said, like a decision record.
python3 00-META/checks/words.py python3 00-META/checks/words.py
python3 00-META/checks/words.py --list tools python3 00-META/checks/words.py --list tools
python3 00-META/checks/words.py --list lines
""" """
import datetime import datetime
@@ -65,30 +50,11 @@ ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
GLOSSARY = "00-META/glossary.md" GLOSSARY = "00-META/glossary.md"
ALLOWED = "00-META/checks/words-allowed.md" ALLOWED = "00-META/checks/words-allowed.md"
# The day the rule began (ADR 0244): issues opened from it on are held to it. # The day the rule began (ADR 0244): research and issues from it on are held to it.
FROM = "2026-10-07" FROM = "2026-10-07"
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md") ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
# The daemons' lines (ADR 0272, its insight of 2026-10-09), and the day that rule began. "alone" is a
# command that is a line by itself; "with an argument" one that is a line only when written as one.
LINES_FROM = "2026-10-09"
DAEMON_LINES = {
"mesh-controller": {
"alone": ("adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act "
"hand-acts healers identity kill licence migrate operator pause pin plan prepare push rebuild "
"rehearse replay resume retire rollout rotate secret settings status token unassign unpin "
"upgrade").split(),
"with an argument": ("artifacts ask builds bus collection data delivery images mirrors module node "
"overlay plans queue seat seats take version").split(),
},
"mesh-host": {
"alone": "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version".split(),
"with an argument": [],
},
"nox-mesh-host": {"alone": ["hand-over"], "with an argument": []},
}
NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M) NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M)
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?") STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M) IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
@@ -168,26 +134,25 @@ def prose(text, glossary_file=False):
return text return text
def issues(since=FROM): def research_and_issues():
"""The issue reports opened on or after `since`, file by file.""" """The research efforts and issue reports dated on or after FROM, file by file."""
out = [] out = []
base = os.path.join(ROOT, "04-ISSUES") for folder, key, first in (("01-RESEARCH", "initiated", "00-overview.md"), ("04-ISSUES", "opened", "00-report.md")):
if not os.path.isdir(base): base = os.path.join(ROOT, folder)
return out for effort in sorted(os.listdir(base)):
for issue in sorted(os.listdir(base)): head = os.path.join(base, effort, first)
head = os.path.join(base, issue, "00-report.md")
if not os.path.isfile(head): if not os.path.isfile(head):
continue continue
date = frontmatter_field(read(rel(head)), "opened") date = frontmatter_field(read(rel(head)), key)
if not date or date < since: if not date or date < FROM:
continue continue
for name in sorted(os.listdir(os.path.join(base, issue))): for name in sorted(os.listdir(os.path.join(base, effort))):
if name.endswith(".md"): if name.endswith(".md"):
out.append("04-ISSUES/%s/%s" % (issue, name)) out.append("%s/%s/%s" % (folder, effort, name))
return out return out
def checked_documents(since=FROM): def checked_documents():
docs = [] docs = []
for entry in ALWAYS: for entry in ALWAYS:
path = os.path.join(ROOT, entry) path = os.path.join(ROOT, entry)
@@ -199,13 +164,15 @@ def checked_documents(since=FROM):
for name in sorted(files): for name in sorted(files):
if name.endswith(".md"): if name.endswith(".md"):
docs.append(rel(os.path.join(base, name))) docs.append(rel(os.path.join(base, name)))
return docs + issues(since) return docs + research_and_issues()
def allowed(): def allowed():
"""words-allowed.md: one row per document — | `document` | until | why |. """words-allowed.md: one row per document — | `document` | until | why |.
`until` is a date, after which the allowance fails like the words themselves. `until` is a date, after which the allowance fails like the words themselves, or `kept` for a
document that records what was said and must keep its words: only a research effort that has
graduated may be kept, because once it has become a decision it is a record like one.
""" """
out, problems = {}, [] out, problems = {}, []
if not os.path.isfile(os.path.join(ROOT, ALLOWED)): if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
@@ -218,8 +185,14 @@ def allowed():
doc, until, why = cells[0].strip("`"), cells[1], cells[2] doc, until, why = cells[0].strip("`"), cells[1], cells[2]
if not why: if not why:
problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc)) problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc))
if not re.match(r"^\d{4}-\d{2}-\d{2}$", until): if until == "kept":
problems.append("%s: the allowance for %s has no date" % (ALLOWED, doc)) overview = os.path.join(os.path.dirname(doc), "00-overview.md")
status = frontmatter_field(read(overview), "status") if os.path.isfile(os.path.join(ROOT, overview)) else None
if not doc.startswith("01-RESEARCH/") or status != "graduated":
problems.append("%s: %s is kept, but only a graduated research effort may be" % (ALLOWED, doc))
continue
elif not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
problems.append("%s: the allowance for %s has neither a date nor `kept`" % (ALLOWED, doc))
continue continue
elif until < today: elif until < today:
problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until)) problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until))
@@ -250,60 +223,6 @@ def check_retired(retired, identifiers):
return problems return problems
def lines_list():
"""The daemons' lines as the catalogue's copy lists them: `<daemon> <command>`, ` …` when an argument must follow."""
out = []
for daemon, kinds in DAEMON_LINES.items():
out += ["%s %s" % (daemon, c) for c in kinds["alone"]]
out += ["%s %s …" % (daemon, c) for c in kinds["with an argument"]]
return sorted(out)
def line_pattern(daemon, command, with_argument):
"""The line whole: the daemon, white space, the command. One that is a line only with an argument is matched
as a line is written — at the start of a line or a code span, a prompt allowed, an argument after it."""
d, c = re.escape(daemon), re.escape(command)
if with_argument:
return re.compile(r"(?m)(?:^|`)[ \t]*(?:\$[ \t]*)?(" + d + r"\s+" + c + r"\s+[^\s`])")
return re.compile(r"(?<![\w-])" + d + r"\s+" + c + r"(?![\w-])")
def own_words(text):
"""The text with what is not the document's own words blanked: as prose(), but a code block and a code span
are kept, since a line to type is written in one."""
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
for step in (
r"(?s)<!--.*?-->", # comments
r"(?m)^\s*>.*$", # block quotes
r"\]\([^)\s]*\)", # link targets
r"~~[^~\n]+~~", # struck through
r"\"[^\"\n]*\"", # "quoted"
r"\u201c[^\u201d]*\u201d", # curly double quotes
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
):
text = re.sub(step, blank, text)
return text
def check_lines():
problems = []
patterns = []
for daemon, kinds in DAEMON_LINES.items():
patterns += [(daemon, c, line_pattern(daemon, c, False)) for c in kinds["alone"]]
patterns += [(daemon, c, line_pattern(daemon, c, True)) for c in kinds["with an argument"]]
for doc in checked_documents(LINES_FROM):
text = own_words(read(doc))
for daemon, command, rx in patterns:
for m in rx.finditer(text):
line = text.count("\n", 0, m.start()) + 1
problems.append("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)"
% (doc, line, daemon, command))
return problems
def check_unique(retired, heads): def check_unique(retired, heads):
problems = [] problems = []
seen = {} seen = {}
@@ -334,42 +253,29 @@ def check_copy(retired):
except OSError as err: except OSError as err:
return ["the catalogue's copy of the retired words cannot be read: %s" % err] return ["the catalogue's copy of the retired words cannot be read: %s" % err]
want = tools_list(retired) want = tools_list(retired)
problems = []
if sorted(copy, key=str.lower) != want: if sorted(copy, key=str.lower) != want:
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want)) return ["the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want)]
path = os.path.join(catalogue, "retired-lines") return []
try:
with open(path, encoding="utf-8") as handle:
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
except OSError as err:
return problems + ["the catalogue's copy of the daemons' lines cannot be read: %s" % err]
if sorted(copy) != lines_list():
problems.append("the catalogue's retired-lines differs from this check's list: regenerate it with "
"`python3 00-META/checks/words.py --list lines`")
return problems
def main(argv): def main(argv):
retired, identifiers, heads = glossary() retired, identifiers, heads = glossary()
if argv[1:2] == ["--list"]: if argv[1:2] == ["--list"]:
scope = argv[2] if len(argv) > 2 else "tools" scope = argv[2] if len(argv) > 2 else "tools"
if scope == "lines":
words = lines_list()
else:
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)}) words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
print("\n".join(words)) print("\n".join(words))
return 0 return 0
if not retired: if not retired:
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything") print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
return 1 return 1
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired) problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_copy(retired)
for p in problems: for p in problems:
print(p) print(p)
if problems: if problems:
print("words: %d problem(s)" % len(problems)) print("words: %d problem(s)" % len(problems))
return 1 return 1
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; " print("words: %d retired words and %d identifiers, none in running prose; %d head words, each once"
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads))) % (len(retired), len(identifiers), len(heads)))
return 0 return 0
+78 -225
View File
@@ -4,7 +4,7 @@ One name per thing. This page is the authority; where an older record says somet
record keeps its words and this page says how to read them. It exists because the words kept drifting record keeps its words and this page says how to read them. It exists because the words kept drifting
in conversation — "control plane", "controller" and "master" for one thing, "substrate" and in conversation — "control plane", "controller" and "master" for one thing, "substrate" and
"foundation" for another — and a mesh you cannot name precisely is a mesh two people describe differently. The rule and "foundation" for another — and a mesh you cannot name precisely is a mesh two people describe differently. The rule and
its check are ADR 0244; its check are [ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md);
the domains are drawn in [to-be 49](../03-DESIGN/01-to-be/49-the-mesh-in-domains.md). the domains are drawn in [to-be 49](../03-DESIGN/01-to-be/49-the-mesh-in-domains.md).
## How to read this page ## How to read this page
@@ -30,33 +30,31 @@ is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
- **mesh** — the whole: the nodes, the modules assigned to them, the controller that decides and the - **mesh** — the whole: the nodes, the modules assigned to them, the controller that decides and the
records it keeps. One mesh per operator; a second mesh is a second everything. records it keeps. One mesh per operator; a second mesh is a second everything.
- **domain** — an area of the mesh that owns a set of concepts and the one word for each, as described - **domain** — an area of the mesh that owns a set of concepts and the one word for each, as described
in the section above (ADR 0244). in the section above ([ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
ADR 0006 called seven of them the controller's *contexts*; they carry over as domains under the same ADR 0006 called seven of them the controller's *contexts*; they carry over as domains under the same
names, except *observability*, which is now **Health and repair**. Where a domain's records live in names, except *observability*, which is now **Health and repair**. Where a domain's records live in
the controller, the domain owns that store alone (ADR 0008). the controller, the domain owns that store alone ([ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md)).
*Context* in that sense is not used in new writing; the word stays ordinary English, so this is *Context* in that sense is not used in new writing; the word stays ordinary English, so this is
checked by review, not by `words.py`. checked by review, not by `words.py`.
- **machine** — any computer: hardware, a virtual machine, before, during or outside its membership of - **machine** — any computer: hardware, a virtual machine, before, during or outside its membership of
the mesh. Prose about the computer itself — its disks, its kernel, the network it sits on, what was on the mesh. Prose about the computer itself — its disks, its kernel, the network it sits on, what was on
it before the mesh — says machine. it before the mesh — says machine.
- **node** — a machine the mesh has adopted and owns. A machine becomes a node when it joins - **node** — a machine the mesh has adopted and owns. A machine becomes a node when it joins
(ADR 0004), and from then on it runs the node-engine ([ADR 0004](../02-DECISIONS/0004-a-node-and-how-it-joins.md)), and from then on it runs the node-engine
and is either **adopted** (the mesh holds what it found there as found) or **converged** (the mesh made and is either **adopted** (the mesh holds what it found there as found) or **converged** (the mesh made
it what it is) — ADR 0100. it what it is) — [ADR 0100](../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md).
Prose about a member of the mesh says node. A node is just a machine that has joined; being one Prose about a member of the mesh says node. A node is just a machine that has joined; being one
implies nothing about what it runs. implies nothing about what it runs.
- **module** — the unit the mesh assigns: one named thing, defined by its manifest, that a node runs — - **module** — the unit the mesh assigns: one named thing, defined by its manifest, that a node runs —
a service, a container, files, packages, its own code — and the tools it serves a service, a container, files, packages, its own code — and the tools it serves
(ADR 0040). Everything configurable on a node is a module. ([ADR 0040](../02-DECISIONS/0040-what-a-module-is.md)). Everything configurable on a node is a module.
- **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a **closed - **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a **closed
set** the mesh defines: a claim naming a seat outside the set is refused. A seat may **deliver a set** the mesh defines: a claim naming a seat outside the set is refused. A seat may **deliver a
provision**, and its holder is then the mesh's answer for it when several modules provide it provision**, and its holder is then the mesh's answer for it when several modules provide it
(ADR 0126). The set, with who holds each ([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). The set, with who holds each
seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)).
A seat carries **verbs** — the tools every holder must serve. A verb added to a held seat is first A seat carries **verbs** — the tools every holder must serve. A verb added to a held seat is first
promised as optional, then served, then required (ADR 0246). promised as optional, then served, then required ([ADR 0246](../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md)).
A node seat may also name **the paths its holder owns**, such as `/etc/hosts`: a claim whose module does
not declare them is refused, and only a claimant declares them (ADR 0287).
- **operator** — the person who runs the mesh, and the one the mesh talks to. - **operator** — the person who runs the mesh, and the one the mesh talks to.
*Not:* ~~master~~ (hq) *Not:* ~~master~~ (hq)
- **person** — any human, as against the mesh acting unattended: *a person's word* releases a held - **person** — any human, as against the mesh acting unattended: *a person's word* releases a held
@@ -81,15 +79,15 @@ are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0
- **claim** — a module taking a spot on a seat: `claims: [{name, scope}]` in a manifest. A mesh-scoped - **claim** — a module taking a spot on a seat: `claims: [{name, scope}]` in a manifest. A mesh-scoped
exclusive claim is how the mesh says "there is one of me". A foundation seat is named after the role exclusive claim is how the mesh says "there is one of me". A foundation seat is named after the role
it guards: the `mesh-controller`, `postgres` and `nats` modules claim the `mesh-controller`, it guards: the `mesh-controller`, `postgres` and `nats` modules claim the `mesh-controller`,
`mesh-store` and `mesh-broker` seats (ADR 0079, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md),
ADR 0116). [ADR 0116](../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md)).
- **setting** — a value a manifest declares and an assignment gives, one of the two ways a node varies - **setting** — a value a manifest declares and an assignment gives, one of the two ways a node varies
a module (ADR 0174). a module ([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
What a "flavor" once varied is a setting or a separate module. What a "flavor" once varied is a setting or a separate module.
*Not:* ~~flavor~~ *Not:* ~~flavor~~
- **preference** — a setting whose manifest gives it a default, because its value is true of the - **preference** — a setting whose manifest gives it a default, because its value is true of the
software and only tuned per node: a font size, a width software and only tuned per node: a font size, a width
(ADR 0262). ([ADR 0262](../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md)).
A setting with no default is the operator's own value: a domain, an identity. A setting with no default is the operator's own value: a domain, an identity.
*Not:* ~~tunable~~ (hq) *Not:* ~~tunable~~ (hq)
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own - **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
@@ -98,7 +96,7 @@ are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0
- **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a - **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a
daemon — in any language the mesh has a toolchain for; never an image. A **tools bundle** speaks MCP to daemon — in any language the mesh has a toolchain for; never an image. A **tools bundle** speaks MCP to
the tool runner. One module may declare several the tool runner. One module may declare several
(ADR 0188). ([ADR 0188](../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
- **tool** — one capability a module serves through the tool runner, addressed `<module>.<tool>`. A - **tool** — one capability a module serves through the tool runner, addressed `<module>.<tool>`. A
seat's **verb** is the same thing carried by a seat rather than a module. seat's **verb** is the same thing carried by a seat rather than a module.
- **mesh-sdk** — the library a module's own code is written against, including the harness that serves a - **mesh-sdk** — the library a module's own code is written against, including the harness that serves a
@@ -108,7 +106,7 @@ are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0
one. A grant on the publish side and nothing else; a module that declares none calls nothing. one. A grant on the publish side and nothing else; a module that declares none calls nothing.
- **upgrade policy** — how a module's new builds reach its nodes: `roll` (one node first, judged, then - **upgrade policy** — how a module's new builds reach its nodes: `roll` (one node first, judged, then
the rest), `together`, or `record` (register the build, send it nowhere) the rest), `together`, or `record` (register the build, send it nowhere)
(ADR 0236). ([ADR 0236](../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md)).
The controller's verb `upgrade` shows it. The controller's verb `upgrade` shows it.
## Core — the mesh's own machinery ## Core — the mesh's own machinery
@@ -116,7 +114,7 @@ are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0
**Purpose.** To keep the controller, the bus, the store and every node's engine running and agreed, so **Purpose.** To keep the controller, the bus, the store and every node's engine running and agreed, so
every other domain has something to run on. **Recorded by** the controller and the store. every other domain has something to run on. **Recorded by** the controller and the store.
**Upstream of** every domain but Module; the others use the bus and the store as they are. **Upstream of** every domain but Module; the others use the bus and the store as they are.
**Decided by** ADR 0005, 0006, 0067, 0106, 0141, 0175, 0227, 0229, 0289. **Decided by** ADR 0005, 0006, 0067, 0106, 0141, 0175, 0227, 0229.
**Uses:** node, module, seat. **Uses:** node, module, seat.
- **controller** — the component that decides what each node should be, holds the mesh's records, and - **controller** — the component that decides what each node should be, holds the mesh's records, and
@@ -137,35 +135,33 @@ every other domain has something to run on. **Recorded by** the controller and t
in the tools' descriptions it is also an SSH server's or a container runtime's own word, so a in the tools' descriptions it is also an SSH server's or a container runtime's own word, so a
description that means the node-engine by it is found by review. description that means the node-engine by it is found by review.
*Not:* ~~host agent~~, ~~the host~~ (hq), ~~node host~~ *Not:* ~~host agent~~, ~~the host~~ (hq), ~~node host~~
*Identifier until renamed:* `mesh-host` — the repository, its binary and its service unit; the rename is *Identifier until renamed:* `mesh-host` — the repository, its binary and its service unit
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
- **tool runner** — the one program on each node that loads every assigned module's tools bundle and - **tool runner** — the one program on each node that loads every assigned module's tools bundle and
serves every tool and every held seat's verb on the subjects the memberships issue; the node-engine serves every tool and every held seat's verb on the subjects the memberships issue; the node-engine
supervises it as a process beside the services it runs, never a container supervises it as a process beside the services it runs, never a container
(ADR 0175, ([ADR 0175](../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
which called it "node tools"). Its mode on loopback is the mesh MCP server. which called it "node tools"). Its mode on loopback is the mesh MCP server.
*Not:* ~~node tools~~, ~~tool runtime~~ (hq) *Not:* ~~node tools~~, ~~tool runtime~~ (hq)
*Identifier until renamed:* `node-tools` — the module, its unit and its bus account; the rename is *Identifier until renamed:* `node-tools` — the module, its unit and its bus account
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
- **foundation** — the store and the bus, raised at genesis before any module system exists. The - **foundation** — the store and the bus, raised at genesis before any module system exists. The
foundation is not a third thing beside the store and the bus — it *is* those two, named together. foundation is not a third thing beside the store and the bus — it *is* those two, named together.
*Not:* ~~substrate~~ *Not:* ~~substrate~~
- **genesis** — raising the foundation and the controller on an empty mesh, by the one installation - **genesis** — raising the foundation and the controller on an empty mesh, by the one installation
done by hand (ADR 0067). done by hand ([ADR 0067](../02-DECISIONS/0067-genesis-is-a-pivot.md)).
- **store** — the one postgres server. It holds the controller's own databases (`inventory`, - **store** — the one postgres server. It holds the controller's own databases (`inventory`,
`identity`, `licences`, each owned by one domain, ADR 0008) and every module's own database. One server, `identity`, `licences`, each owned by one domain, ADR 0008) and every module's own database. One server,
many databases — never one shared mesh database. Bare *store* means this and nothing else many databases — never one shared mesh database. Bare *store* means this and nothing else
(see [Homonyms](#homonyms)). (see [Homonyms](#homonyms)).
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has — - **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
control, declarations, builds, events, tool calls (ADR 0106). control, declarations, builds, events, tool calls ([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)).
A module reaches it by requiring `mesh-bus` (ADR 0128); A module reaches it by requiring `mesh-bus` ([ADR 0128](../02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md));
one that does not require it has no account on it. Held by the `mesh-broker` seat, which is named one that does not require it has no account on it. Held by the `mesh-broker` seat, which is named
after the role rather than the server, so the server can change without the seat doing so. The `nats` after the role rather than the server, so the server can change without the seat doing so. The `nats`
module holds it. module holds it.
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It keeps - **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It keeps
running as an **ordinary provider** of the `amqp` provision, for modules that need a message broker running as an **ordinary provider** of the `amqp` provision, for modules that need a message broker
of their own the way something needs a database of their own the way something needs a database
(ADR 0131) — it claims no ([ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) — it claims no
seat, is not foundation, is never raised at genesis, and a mesh that never installs it is complete. seat, is not foundation, is never raised at genesis, and a mesh that never installs it is complete.
Say *the deprecated broker*, not *the compatibility broker* (it serves the mesh's own modules, not only Say *the deprecated broker*, not *the compatibility broker* (it serves the mesh's own modules, not only
the predecessor's) and not *the AMQP broker* (naming it after a protocol invites describing the bus by the predecessor's) and not *the AMQP broker* (naming it after a protocol invites describing the bus by
@@ -175,15 +171,7 @@ every other domain has something to run on. **Recorded by** the controller and t
this; new prose says layer (see [Homonyms](#homonyms)). this; new prose says layer (see [Homonyms](#homonyms)).
- **lease** and **epoch** — which controller may send (the lease, held in the bus and renewed), and the - **lease** and **epoch** — which controller may send (the lease, held in the bus and renewed), and the
order of what it sent (the epoch, which a node reads before it accepts a declaration) order of what it sent (the epoch, which a node reads before it accepts a declaration)
(ADR 0229). ([ADR 0229](../02-DECISIONS/0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)).
- **record mirror** — a key-value bucket on the bus that the controller writes from one kind of its
records (nodes, assignments, walks, builds, settings), after each change commits in its store and only
when the value differs, with one event per write. The store stays the record; the self-check compares
the two and raises `mirror-drifted` on any difference
(ADR 0289). Shortened to *mirror* where the
text is about the controller's records; the builder's *mirrored* `upstream` image (Change and delivery) is
a different thing, a copy of an outside image in the artifact store.
## Placement — what runs on which node ## Placement — what runs on which node
@@ -195,23 +183,23 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
- **assignment** — a module put on a node, with the settings that node gives it. - **assignment** — a module put on a node, with the settings that node gives it.
- **pending assignment** — an assignment of a module whose build is still running, kept, and made by the - **pending assignment** — an assignment of a module whose build is still running, kept, and made by the
controller when that build registers the module, or ended with why when it does not controller when that build registers the module, or ended with why when it does not
(ADR 0261). ([ADR 0261](../02-DECISIONS/0261-an-assignment-made-while-its-modules-build-runs-is-kept-pending-and-the-controller-makes-it.md)).
- **scope** — where a seat or a claim holds: `node`, `site` or `mesh`. `node` is also the scope's - **scope** — where a seat or a claim holds: `node`, `site` or `mesh`. `node` is also the scope's
value in a manifest. value in a manifest.
- **capacity** — how many holders a seat takes. A capacity-1 seat is exclusive. A higher-capacity seat - **capacity** — how many holders a seat takes. A capacity-1 seat is exclusive. A higher-capacity seat
is a **bench**: several holders coexist. A **replicated** bench has one holder per node, each on record is a **bench**: several holders coexist. A **replicated** bench has one holder per node, each on record
and each answering the same, like `mesh-dns-resolver` and each answering the same, like `mesh-dns-resolver`
(ADR 0223). A ([ADR 0223](../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md)). A
**kinded** bench has holders that are different modules, each claiming one **kind**, and a verb's **kinded** bench has holders that are different modules, each claiming one **kind**, and a verb's
subject carries the kind; `channel` and `intake` are the only ones (ADR 0234). subject carries the kind; `channel` and `intake` are the only ones (ADR 0234).
- **installed / holding** — a module may be assigned (its package installed, its files placed) without - **installed / holding** — a module may be assigned (its package installed, its files placed) without
holding the seat its family declares; *holding* is being the one — the login shell, the display session holding the seat its family declares; *holding* is being the one — the login shell, the display session
— on that node (ADR 0176). — on that node ([ADR 0176](../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
The module that holds a seat is its **holder**. The module that holds a seat is its **holder**.
- **depends on a seat** — a module needing a seat held on its node by some module, without holding it. - **depends on a seat** — a module needing a seat held on its node by some module, without holding it.
Derived from the resources it declares, never stated: a `service` depends on `node-service-manager`, a Derived from the resources it declares, never stated: a `service` depends on `node-service-manager`, a
`package` on `node-package-manager`, a `container` on `node-container-runtime` `package` on `node-package-manager`, a `container` on `node-container-runtime`
(ADR 0207). ([ADR 0207](../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)).
Not a claim: a module claims a seat it holds and declares resources. Not a claim: a module claims a seat it holds and declares resources.
- **declaration** — what the controller sends one node: every resource of every module assigned there, - **declaration** — what the controller sends one node: every resource of every module assigned there,
composed. The noun is this domain's; what a manifest says is that it *declares* (Module), and a manifest composed. The noun is this domain's; what a manifest says is that it *declares* (Module), and a manifest
@@ -224,16 +212,16 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
- **operator account** — the login name of the person who works on a node, stated on the node record; - **operator account** — the login name of the person who works on a node, stated on the node record;
empty for a node nobody logs into. Everything the mesh places under a person's home is resolved empty for a node nobody logs into. Everything the mesh places under a person's home is resolved
against this account's home and owned by it against this account's home and owned by it
(ADR 0181). ([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
Not a name a manifest carries. Not a name a manifest carries.
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own; - **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents
run as the operator account. It cannot become root without a person, and the node-engine judges that on run as the operator account. It cannot become root without a person, and the node-engine judges that on
every look (ADR 0266). every look ([ADR 0266](../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)).
- **account group** — a group of the machine's group database that a module puts an account in, by - **account group** — a group of the machine's group database that a module puts an account in, by
declaring the account with that group and nothing else of it. Several modules may each add one to the declaring the account with that group and nothing else of it. Several modules may each add one to the
same account; the mesh takes back only an account group it added same account; the mesh takes back only an account group it added
(ADR 0252). ([ADR 0252](../02-DECISIONS/0252-a-module-puts-an-account-in-a-group-and-the-mesh-says-when-a-new-login-is-needed.md)).
Never *membership*, which is the bus's word. Never *membership*, which is the bus's word.
## Provisioning — one module serving another ## Provisioning — one module serving another
@@ -249,15 +237,15 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
a module *the* provider of it. a module *the* provider of it.
- **provider** and **consumer** — the module that provides a provision, and the module that requires - **provider** and **consumer** — the module that provides a provision, and the module that requires
it. Which of several providers serves a consumer is resolved it. Which of several providers serves a consumer is resolved
(ADR 0084). ([ADR 0084](../02-DECISIONS/0084-which-provider-serves-a-consumer.md)).
- **pin** — a person's choice of provider for a consumer, which resolution then respects. - **pin** — a person's choice of provider for a consumer, which resolution then respects.
- **endpoint** — where a consumer reaches its provider; an assignment binds it and says how far it - **endpoint** — where a consumer reaches its provider; an assignment binds it and says how far it
reaches (ADR 0138). reaches ([ADR 0138](../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)).
- **retire** — a provider stops serving a consumer the mesh no longer asks for; what it kept is deleted - **retire** — a provider stops serving a consumer the mesh no longer asks for; what it kept is deleted
only by a person (ADR 0230). only by a person ([ADR 0230](../02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)).
- **share** — a folder one node offers to the mesh's other nodes over the private network: the provision - **share** — a folder one node offers to the mesh's other nodes over the private network: the provision
`nfs-share`, provided by the holder of the node seat `node-nfs-server`, one share per folder `nfs-share`, provided by the holder of the node seat `node-nfs-server`, one share per folder
(ADR 0263). ([ADR 0263](../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md)).
- **mount** — a share, or an occasional source, made visible at a path on one node by the holder of the - **mount** — a share, or an occasional source, made visible at a path on one node by the holder of the
node seat `node-mounts`, as a mount unit and an automount unit, mounted on access (ADR 0263). node seat `node-mounts`, as a mount unit and an automount unit, mounted on access (ADR 0263).
- **occasional source** — a share on a device that is not a node and comes and goes, such as a USB disk; - **occasional source** — a share on a device that is not a node and comes and goes, such as a USB disk;
@@ -274,51 +262,41 @@ and the operator act. **Recorded by** the controller's `identity` and `licences`
- **credential** — anything that proves an identity: a password, a key, a token. A **pair credential** - **credential** — anything that proves an identity: a password, a key, a token. A **pair credential**
is the two ends of one credential, the consumer's and the provider's. is the two ends of one credential, the consumer's and the provider's.
- **secret** — a credential or other value the vault keeps sealed and the node-engine unseals into a - **secret** — a credential or other value the vault keeps sealed and the node-engine unseals into a
file at apply (ADR 0085). file at apply ([ADR 0085](../02-DECISIONS/0085-a-secret-is-a-provision.md)).
- **secret family** — a module's own secrets declared as one name ending in `-*`; each member is the prefix
and a name (`smb-password-<source>`, one per source the module's settings name), given by its full name
and never made by the mesh
(ADR 0283).
- **vault** — the module that owns every secret (`mesh-vault`, - **vault** — the module that owns every secret (`mesh-vault`,
[24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)). [24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)).
- **grant** — what an identity may call or reach, bounded by the provisions it requires - **grant** — what an identity may call or reach, bounded by the provisions it requires
(ADR 0225). ([ADR 0225](../02-DECISIONS/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md)).
- **bus account** and **membership** — a module's or a node's account on the bus, and the subjects it - **bus account** and **membership** — a module's or a node's account on the bus, and the subjects it
is issued. The bus server calls its accounts *users*; the mesh says bus account. is issued. The bus server calls its accounts *users*; the mesh says bus account.
- **rotate** — replacing a credential without a consumer holding one the provider does not know. - **rotate** — replacing a credential without a consumer holding one the provider does not know.
- **licence** — a model-access account the licence manager holds and **binds** to a consumer, the - **licence** — a model-access account the licence manager holds and **binds** to a consumer, the
coding agent on a node (ADR 0183). coding agent on a node ([ADR 0183](../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)).
Taking a consumer off a licence is **unbinding** it; the licence manager's verb for it is still Taking a consumer off a licence is **unbinding** it; the licence manager's verb for it is still
`release`, an identifier to rename. `release`, an identifier to rename.
- **proof** — what an authorising answer carries: a verified sender, a one-time code, a security key's - **proof** — what an authorising answer carries: a verified sender, a one-time code, a security key's
touch (ADR 0234). How much proof a kind of ask needs is its **assurance level**. touch (ADR 0234). How much proof a kind of ask needs is its **assurance level**.
- **controller's terminal** — a command line the controller runs as typed by its operator, not asked through a - **controller's terminal** — a command line the controller runs as typed by its operator, not asked through a
verb, so a change that is the terminal's alone is taken (issue 339, issue 340) — or, since verb, so a change that is the terminal's alone is taken (issue 339, issue 340). In the controller's code it is a
ADR 0277,
proposed through a verb and set on the operator's warrant. In the controller's code it is a
line run without `MESH_VERB`. The operator reaches it with `mesh-cli` from their own account on a node where no line run without `MESH_VERB`. The operator reaches it with `mesh-cli` from their own account on a node where no
agent can run as that account: the control-node, and a node whose agents run under an account of their own agent can run as that account: the control-node, and a node whose agents run under an account of their own
(ADR 0272). ([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
## Change and delivery — a commit on its way to the nodes ## Change and delivery — a commit on its way to the nodes
**Purpose.** To take one commit from its pull request to every node that should run it, and to put it **Purpose.** To take one commit from its pull request to every node that should run it, and to put it
back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the
planner, the walk). **Downstream of** Module, Core, Health and repair and Data. planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
**Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270, 0276, 0323. **Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270.
**Uses:** module, node, declaration and send (Placement), health (Health and repair), person. **Uses:** module, node, declaration and send (Placement), health (Health and repair), person.
- **merge check** — the two statuses a pull request carries before it may merge: the **merge gate** - **merge check** — the two statuses a pull request carries before it may merge: the **merge gate**
(`mesh/merge-gate`, the build seat's composed check of the modules the change touches) and the (`mesh/merge-gate`, the build seat's composed check of the modules the change touches) and the
**repository check** (`mesh/repo-check`, the repository's own `merge-check.sh`) **repository check** (`mesh/repo-check`, the repository's own `merge-check.sh`)
(ADR 0238). Both judge the merge of the pull request's head into a named **trunk commit**, made in the ([ADR 0238](../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)).
build seat's own clone and never pushed (ADR 0323).
- **trunk commit** — the commit of the trunk a merge check's verdict was judged against: the trunk's head when
the build seat cloned it. The verdict holds for that commit only, and goes out of date when the trunk moves
(ADR 0323).
- **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the - **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the
**builder**. Any node may hold the seat, so there is no build node by nature **builder**. Any node may hold the seat, so there is no build node by nature
(ADR 0237). ([ADR 0237](../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)).
*Not:* ~~build machine~~ *Not:* ~~build machine~~
- **build queue** — the controller's queue of builds to run; one entry in it is a **build request**. - **build queue** — the controller's queue of builds to run; one entry in it is a **build request**.
The queue's verbs still say *ask* for an entry; the conversation's ask is another thing The queue's verbs still say *ask* for an entry; the conversation's ask is another thing
@@ -328,14 +306,14 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
- **artifact** — anything a build produces and the mesh delivers to a node by **digest**: an `image`, a - **artifact** — anything a build produces and the mesh delivers to a node by **digest**: an `image`, a
mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by the mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by the
**artifact store**, an OCI registry that holds every kind as content-addressed blobs **artifact store**, an OCI registry that holds every kind as content-addressed blobs
(ADR 0156). ([ADR 0156](../02-DECISIONS/0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)).
An image is one kind of artifact, and a module is not an image. A package and an artifact are two An image is one kind of artifact, and a module is not an image. A package and an artifact are two
protocols, not one store being weak (ADR 0075). protocols, not one store being weak ([ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md)).
- **build source** — the files one artifact is built from, in one or more repositories: for a Go program - **build source** — the files one artifact is built from, in one or more repositories: for a Go program
its import closure (the directories of its packages, its Go module files) and its recipe; for any other its import closure (the directories of its packages, its Go module files) and its recipe; for any other
artifact the paths its manifest names, by default its module's own directory. Each artifact of a build artifact the paths its manifest names, by default its module's own directory. Each artifact of a build
has its own, and a change rebuilds the artifacts whose build source it touches has its own, and a change rebuilds the artifacts whose build source it touches
(ADR 0267). ([ADR 0267](../02-DECISIONS/0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md)).
Older records (ADR 0015, ADR 0111) and designs say *build source* for the repository a module Older records (ADR 0015, ADR 0111) and designs say *build source* for the repository a module
is built from; this entry narrows it to the files within, and that use reads as *the repository holding is built from; this entry narrows it to the files within, and that use reads as *the repository holding
the build source*. Sharing a repository is not a dependency, and no build source is a whole repository the build source*. Sharing a repository is not a dependency, and no build source is a whole repository
@@ -345,20 +323,18 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
*Not:* ~~catalog~~ (hq) *Not:* ~~catalog~~ (hq)
- **delivery** — one commit in one repository on its way to the nodes, from its pull request's head - **delivery** — one commit in one repository on its way to the nodes, from its pull request's head
being announced to one of its final states: one pull request, one status, one note being announced to one of its final states: one pull request, one status, one note
(ADR 0239). ([ADR 0239](../02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)).
From its merge on it is delivered by the walk of the batch that answers it, and ends with that walk
(ADR 0276).
Its states are one table (`proposed`, `checked`, `ready` or `rejected`, `published`, `delivering`, Its states are one table (`proposed`, `checked`, `ready` or `rejected`, `published`, `delivering`,
`held`, and the final six: `delivered`, `failed`, `superseded`, `stopped`, `retired` and `covered`; `held`, and the final six: `delivered`, `failed`, `superseded`, `stopped`, `retired` and `covered`;
ADR 0250, [ADR 0250](../02-DECISIONS/0250-a-merge-heard-only-from-the-buss-history-is-retired-and-owed-work-the-forge-cannot-do-ends.md),
ADR 0270). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy [ADR 0270](../02-DECISIONS/0270-a-held-delivery-that-moves-nothing-or-that-a-later-delivered-merge-contains-ends-by-itself-and-a-hold-whose-reason-is-gone-lifts.md)). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy
mechanism, which the as-is designs describe; it is never a word for a delivery. mechanism, which the as-is designs describe; it is never a word for a delivery.
*Not:* ~~deployment~~ (hq) *Not:* ~~deployment~~ (hq)
- **covered** — the end of a delivery that a later merge containing it delivered: every module it moves was - **covered** — the end of a delivery that a later merge containing it delivered: every module it moves was
sent to every machine running it by the walk of a later merge into the same trunk, delivered on its own sent to every machine running it by the walk of a later merge into the same trunk, delivered on its own
passing check, whose merge commit the forge says contains this one. Not *superseded*, which is a delivery passing check, whose merge commit the forge says contains this one. Not *superseded*, which is a delivery
whose walk a newer delivery took over before it ended; nor *delivered*, which its own walk or an empty plan whose walk a newer delivery took over before it ended; nor *delivered*, which its own walk or an empty plan
ends in (ADR 0270). ends in ([ADR 0270](../02-DECISIONS/0270-a-held-delivery-that-moves-nothing-or-that-a-later-delivered-merge-contains-ends-by-itself-and-a-hold-whose-reason-is-gone-lifts.md)).
- **delivery group** — two or more deliveries sharing a pull request head branch name across the mesh's - **delivery group** — two or more deliveries sharing a pull request head branch name across the mesh's
repositories, delivered as one unit in an order declared (`after:`) or inferred by the planner. One repositories, delivered as one unit in an order declared (`after:`) or inferred by the planner. One
level: a group holds deliveries, never groups. Its state is derived from its members, never set. level: a group holds deliveries, never groups. Its state is derived from its members, never set.
@@ -367,32 +343,16 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
person) and its verdict (the composed nodes, the replays). Computed by the controller's planner from a person) and its verdict (the composed nodes, the replays). Computed by the controller's planner from a
diffset; the controller's verb `delivery-plan` shows it. *Deploy* is used in this one place. diffset; the controller's verb `delivery-plan` shows it. *Deploy* is used in this one place.
*Not:* ~~change plan~~, ~~release plan~~ *Not:* ~~change plan~~, ~~release plan~~
- **merge window** — the rolling wait in which the controller collects merges before it plans them: each
merge restarts it, and it closes when none arrived for its length (90 seconds by default) or at the latest
10 minutes after its first merge (ADR 0276).
- **batch** — the merges one merge window collected, planned once as one walk. For a repository merged more
than once in it, the walk takes the latest commit, and the earlier merges are answered by it. Not *group*,
which is a delivery group.
- **mesh-delivery** — the module that owns deliveries and delivery groups, holding the mesh-scoped seat - **mesh-delivery** — the module that owns deliveries and delivery groups, holding the mesh-scoped seat
of the same name. It records and decides; the controller sends, judges and rolls back when it is asked. of the same name. It records and decides; the controller sends, judges and rolls back when it is asked.
- **walk** — the controller's sending of one batch's builds across nodes, at most one trunk commit per - **walk** — the controller's sending of one trunk commit's builds across nodes, tier by tier, one node
repository, tier by tier, one node first and judged at the first-node gate, then the rest (ADR 0236, first and judged at the first-node gate, then the rest (ADR 0236). A primitive the delivering stage
ADR 0276). One walk is open at a time; it names every merge it answers. A primitive the delivering stage
asks for, not an object anyone manages. A **tier** is one step of a walk: a module, then the modules asks for, not an object anyone manages. A **tier** is one step of a walk: a module, then the modules
built against it. The controller's verb `plans` lists the walks. Walking is not a noun of its own: built against it. The controller's verb `plans` lists the walks. Walking is not a noun of its own:
a module *rolls out* by its `roll` policy. a module *rolls out* by its `roll` policy.
*Not:* ~~rollout~~ (hq) *Not:* ~~rollout~~ (hq)
- **first-node gate** — the judgement on a delivery's first node: healthy three times, no witness put it - **first-node gate** — the judgement on a delivery's first node: healthy three times, no witness put it
back, no condition raised since the send (ADR 0236 §2). Not *the gate* bare (see [Homonyms](#homonyms)). back, no condition raised since the send (ADR 0236 §2). Not *the gate* bare (see [Homonyms](#homonyms)).
- **core module** and **leaf module** — a core module is one on the controller's own path (the controller,
the node-engine, the tool runner, the bus, mesh-delivery) or the mesh's resolver, whose judgement protects
the mesh's ability to reach a node at all; a leaf module is every other module. Decided by
ADR 0282.
Not a *core component* (ADR 0236's controller, node-engine, tool runner and bus, each with a health probe):
the core modules add mesh-delivery and the resolver.
- **delivery time** — from a delivery's merge to every node of its deploy plan that is heard from running its
modules' builds and reporting them healthy. Its **delivery budget** is what it is held to: five minutes for
a leaf module, ten for a core module (ADR 0282). Not the walk's time, which ends at the send.
- **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`. Used in - **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`. Used in
this sense only. this sense only.
- **rollback** — putting a node back on the build it ran before, by something other than the build being - **rollback** — putting a node back on the build it ran before, by something other than the build being
@@ -405,26 +365,21 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
**Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended, **Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended,
and record what was done by hand. **Recorded by** the controller's condition store. and record what was done by hand. **Recorded by** the controller's condition store.
**Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition **Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition
becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283. becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252.
**Uses:** node, module, node-engine (Core). **Uses:** node, module, node-engine (Core).
- **health** — a module's or a core component's statement of how it is alive and ready, which the - **health** — a module's or a core component's statement of how it is alive and ready, which the
node-engine judges (ADR 0240). node-engine judges ([ADR 0240](../02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md)).
- **relogin needed** — an account's health when the group database lists it in an account group and its - **relogin needed** — an account's health when the group database lists it in an account group and its
running session, started before, does not have that group yet. Said by the node-engine as the module's running session, started before, does not have that group yet. Said by the node-engine as the module's
resource of kind `account`; it clears at the first look after a new login (ADR 0252). resource of kind `account`; it clears at the first look after a new login (ADR 0252).
- **waiting** — a resource's health when its module's tool check says it waits for the operator: a named own
secret or setting of the module, not given yet, without which a part cannot work. Not a fault: the
controller checks it and says it as the condition `needs-operator`, naming the act, and the first-node gate
reads it as *waits for a person*
(ADR 0283).
- **probe** — one look at one thing's health, run by whoever owns the verdict; what a probe returns is a - **probe** — one look at one thing's health, run by whoever owns the verdict; what a probe returns is a
**finding**, before it becomes a condition. **finding**, before it becomes a condition.
- **signal** and **watchdog** — something that must keep happening (a heartbeat, a report after a send), - **signal** and **watchdog** — something that must keep happening (a heartbeat, a report after a send),
and the watcher that raises a condition when it stops (to-be 45 §3). and the watcher that raises a condition when it stops (to-be 45 §3).
- **condition** — one open fact about something the mesh owns that is wrong, with a key and a severity; - **condition** — one open fact about something the mesh owns that is wrong, with a key and a severity;
raised and cleared only by observation raised and cleared only by observation
(ADR 0231). ([ADR 0231](../02-DECISIONS/0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)).
What a wrapped dashboard raises is its own; the mesh's notion is a condition. What a wrapped dashboard raises is its own; the mesh's notion is a condition.
*Not:* ~~alert~~ (hq) *Not:* ~~alert~~ (hq)
- **self-check** — the controller's own examination of the mesh, run on demand and on a schedule - **self-check** — the controller's own examination of the mesh, run on demand and on a schedule
@@ -436,7 +391,7 @@ becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283.
never counted as a repair. never counted as a repair.
- **rehearsal** — a question the operator starts at the controller's terminal (`rehearse`) and answers on a - **rehearsal** — a question the operator starts at the controller's terminal (`rehearse`) and answers on a
channel, to see an answer travel the whole way: its approval performs nothing and is recorded as the channel, to see an answer travel the whole way: its approval performs nothing and is recorded as the
operator's decision (ADR 0259). operator's decision ([ADR 0259](../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)).
Not a drill: nothing is broken. Not a drill: nothing is broken.
- **hand-act** — a repair a person made by hand, recorded so the mesh knows it happened. - **hand-act** — a repair a person made by hand, recorded so the mesh knows it happened.
- **witness** — what rolls a core component back when its new build does not become healthy: never the - **witness** — what rolls a core component back when its new build does not become healthy: never the
@@ -451,13 +406,13 @@ becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283.
- **data class** — how precious an item of data is, ranked by the operator: `irreplaceable`, - **data class** — how precious an item of data is, ranked by the operator: `irreplaceable`,
`valuable`, `rebuildable`, `cache`; and `none` for a provision that keeps nothing of anybody's `valuable`, `rebuildable`, `cache`; and `none` for a provision that keeps nothing of anybody's
(ADR 0233). ([ADR 0233](../02-DECISIONS/0233-a-module-declares-the-data-it-holds-and-the-mesh-protects-and-watches-it-from-that.md)).
- **backup** — a copy kept elsewhere by the node's `node-backup` holder; **restore point** — one backup - **backup** — a copy kept elsewhere by the node's `node-backup` holder; **restore point** — one backup
at one moment; **restore** — bringing it back beside the live data, never over it. at one moment; **restore** — bringing it back beside the live data, never over it.
- **stream snapshot** — the bus's own backup of each stream, taken by the module holding `mesh-broker` - **stream snapshot** — the bus's own backup of each stream, taken by the module holding `mesh-broker`
(ADR 0235). ([ADR 0235](../02-DECISIONS/0235-the-bus-is-backed-up-by-its-own-snapshot-of-each-stream.md)).
- **sticky binding** — a consumer's tie to the data a provider keeps for it, which moves only by a - **sticky binding** — a consumer's tie to the data a provider keeps for it, which moves only by a
person (ADR 0232). person ([ADR 0232](../02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md)).
## Connectivity — how nodes and modules reach one another ## Connectivity — how nodes and modules reach one another
@@ -467,19 +422,19 @@ it should not. **Recorded by** the controller. **Upstream of** Provisioning and
**Uses:** node, machine, assignment and endpoint. **Uses:** node, machine, assignment and endpoint.
- **private network** — the mesh's own encrypted network between its nodes, on which every node has an - **private network** — the mesh's own encrypted network between its nodes, on which every node has an
address and a mesh name (ADR 0226). address and a mesh name ([ADR 0226](../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md)).
The network the machine sits on without it is the **underlay**. The network the machine sits on without it is the **underlay**.
*Not:* ~~overlay~~ (hq) *Not:* ~~overlay~~ (hq)
- **anchor** and **hub** — the roles a node plays for the private network: the anchor is reachable from - **anchor** and **hub** — the roles a node plays for the private network: the anchor is reachable from
outside and every node reaches it; a hub relays for nodes that cannot reach each other directly. outside and every node reaches it; a hub relays for nodes that cannot reach each other directly.
- **resolver** — a seat holder that answers the mesh's names; a node lists only the mesh's resolvers - **resolver** — a seat holder that answers the mesh's names; a node lists only the mesh's resolvers
(ADR 0223), or, where it holds one, its own resolver, which asks them. **uplink** — a node's (ADR 0223), or, where it holds one, its own resolver, which asks them. **uplink** — a node's
connection to the outside network, a seat (ADR 0117). **hostname** — a node's own name, connection to the outside network, a seat ([ADR 0117](../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). **hostname** — a node's own name,
a seat. a seat.
- **split DNS** — resolving names by domain on one machine: a VPN's domains through the VPN's servers - **split DNS** — resolving names by domain on one machine: a VPN's domains through the VPN's servers
over its link, every other name through the mesh's resolvers. The provision `split-dns`, provided by over its link, every other name through the mesh's resolvers. The provision `split-dns`, provided by
the holder of the node seat `node-resolver`, the machine's **own resolver**, which exists only where the holder of the node seat `node-resolver`, the machine's **own resolver**, which exists only where
something requires it (ADR 0247). something requires it ([ADR 0247](../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md)).
- **proxy** and **public name** — the module that answers a public name and forwards it to an - **proxy** and **public name** — the module that answers a public name and forwards it to an
endpoint on the private network. endpoint on the private network.
- **packet filter** — what the mesh enforces on a node about which packets pass, the - **packet filter** — what the mesh enforces on a node about which packets pass, the
@@ -508,27 +463,22 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
`/usr/local/bin/mesh-cli <command line>` asks the controller to run one of its command lines. It asks the `/usr/local/bin/mesh-cli <command line>` asks the controller to run one of its command lines. It asks the
node-engine on its own machine, which says to the controller which account is asking; the controller runs the node-engine on its own machine, which says to the controller which account is asking; the controller runs the
line as the **controller's terminal** or as an ordinary call line as the **controller's terminal** or as an ordinary call
(ADR 0272). ([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
- **nox** — `mesh-cli`'s alias, in interactive zsh only. A script, `sudo` and a document meant to work everywhere - **nox** — `mesh-cli`'s alias, in interactive zsh only. A script, `sudo` and a document meant to work everywhere
say `mesh-cli`. The operator also calls the mesh itself *nox mesh*; on this page and in the records *nox* is the say `mesh-cli`. The operator also calls the mesh itself *nox mesh*; on this page and in the records *nox* is the
alias alone. It is the one command the operator types: every line the mesh addresses to the operator — a alias alone.
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
(ADR 0272,
its insight of 2026-10-09).
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel` - **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities** from sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities** from
the fixed vocabulary `channel-capabilities/1`. Not *notifier* (that is the desktop's node seat, one the fixed vocabulary `channel-capabilities/1`. Not *notifier* (that is the desktop's node seat, one
holder of kind `desktop`) and not *bot* (that is one service's account). holder of kind `desktop`) and not *bot* (that is one service's account).
- **router** — the module that holds the `operator-channel` seat, orders the channels and checks every - **router** — the module that holds the `operator-channel` seat, orders the channels and checks every
sender (ADR 0234). sender ([ADR 0234](../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md)).
- **headline / verdict / explanation / resolved line** — what a condition says to the operator in plain - **headline / verdict / explanation / resolved line** — what a condition says to the operator in plain
words: a few words naming the thing and what is wrong; "Nothing for you to do." or "Needs you:" and the words: a few words naming the thing and what is wrong; "Nothing for you to do." or "Needs you:" and the
one thing the operator can do themselves (its **needs**); one or two sentences on what happened and what one thing the operator can do themselves (its **needs**); one or two sentences on what happened and what
it means; the line said when it clears. Its **actions** are the answers a notification offers. Carried by it means; the line said when it clears. Its **actions** are the answers a notification offers. Carried by
the condition beside its **summary**, which keeps the plans, commits and verbs for whoever looks closer the condition beside its **summary**, which keeps the plans, commits and verbs for whoever looks closer
(ADR 0253). ([ADR 0253](../02-DECISIONS/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md)).
The router may override "Nothing for you to do." in what it says, from the **tracking line** (ADR 0285).
- **ask** — a request for the operator's input, of a declared kind (yes-no, one-of, text, number, - **ask** — a request for the operator's input, of a declared kind (yes-no, one-of, text, number,
date, acknowledge). An **authorising ask** is one whose chosen option its asker performs. The router date, acknowledge). An **authorising ask** is one whose chosen option its asker performs. The router
holds every ask, checks each answer's proofs and issues a **warrant**; the asker acts on it with its holds every ask, checks each answer's proofs and issues a **warrant**; the asker acts on it with its
@@ -536,14 +486,6 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
- **warrant** — the router's record that a person chose one option of one ask: the ask, the option, - **warrant** — the router's record that a person chose one option of one ask: the ask, the option,
who chose (the operator, as a channel kind's identity), through which channel, the proofs and when, who chose (the operator, as a channel kind's identity), through which channel, the proofs and when,
published for that asker alone. The only thing an asker acts on (ADR 0259). published for that asker alone. The only thing an asker acts on (ADR 0259).
- **proposal** — a change an agent, or anyone the bus admits, puts forward through a verb and that takes
effect only on the operator's warrant, bound to the exact thing shown: a shared instruction section (ADR
0274), a trusted setting's layer (ADR 0277).
A proposal changes nothing by itself; declined, expired or refused, it is discarded.
- **approval mark** — a tool's or verb's declaration in its manifest (`needs-approval`) that each call to it runs
only on the operator's warrant for that one call, with its exact arguments; the tool runner that serves it, or
the controller for its own verbs, asks and checks
(ADR 0286).
- **operator message / input** — what arrives on `intake`, once the router has checked the sender - **operator message / input** — what arrives on `intake`, once the router has checked the sender
against its own list of the operator's identities: a **trusted** one is an operator message, against its own list of the operator's identities: a **trusted** one is an operator message,
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
@@ -553,104 +495,21 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
of them (a path, an address); opened with `detail` only on a `private` channel or through the mesh MCP server. of them (a path, an address); opened with `detail` only on a `private` channel or through the mesh MCP server.
- **agent** — a coding agent: the operator's, or one the mesh runs. Not the node-engine, and not the - **agent** — a coding agent: the operator's, or one the mesh runs. Not the node-engine, and not the
build seat's holder, whose seat name `node-build-agent` is an identifier to rename. build seat's holder, whose seat name `node-build-agent` is an identifier to rename.
- **instruction section** — one named section of the managed instruction file, which every agent session on the
nodes it is for reads first, as the mesh's word: set at the controller's terminal in the agent module's
settings, or **proposed** by an agent and made active on each node only by the operator's warrant, which that node
proves itself (ADR 0274). A hand-over between sessions is one.
- **personal rule** — one short text in the operator's own words about how the operator wants their agent
to work with them, registered for the operator (the **operator scope** of the agent module) and rendered
into the agent's home on every node that names an operator account. Not a mesh-wide instruction section,
which is the mesh's word about the mesh, set at the controller's terminal
(ADR 0273).
- **lesson** — an agent's proposal, recorded when the operator expresses frustration or corrects a way of
working: the trigger (the operator's words), the cause (what the agent did) and the rule that would
prevent it. A lesson is `proposed`, `accepted` or `declined`; an accepted lesson is a personal rule, and a
proposed one is never read as one (ADR 0273).
## Media — what a person watches, listens to and reads
**Purpose.** To find, fetch and file copies of the works a person watches, listens to and reads, each in
the library its kind names. **Recorded by** the `media-importer` module's settings and its memory of what
it was told, and the servarr apps' own libraries. **Upstream of** nothing. **Decided by** ADR 0295, 0296,
0302.
**Uses:** module, seat, setting (Module).
- **content** — a work a person watches, listens to or reads: a film, an album and its tracks, an
episode, a book, a session of a sport event. It is known by an identity from a catalogue outside the
mesh, never by a file's name, and a work with no copy anywhere is still content (ADR 0302).
- **kind** — a kind of content, declared as configuration, never code: its parts, the metadata fields
that identify a work, the file extensions of its copies, and its folder and file templates. The first
are music, movie, series, audiobook, ebook and sport event.
- **media release** — one copy of a work an engine can fetch, such as an NZB or a video. Downloaded, it
sits in the downloads folder, not yet filed. ADR 0302 says *release*.
- **library** — one folder of one kind, filed by one importer: the media-importer's, named in its
settings, or a servarr app's. Several libraries may hold one kind, as TV Shows and Anime both hold
series.
- **media file** — a file on disk together with its metadata: its kind, the fields that identify the
work, and what part of the work the file is. It is what the media-importer takes.
- **media-importer** — the module that files a media file into its library at the place its kind's
templates make of its metadata. It files only the downloads it was told about, and never into a
servarr app's library (ADR 0302).
- **download client** — a module that fetches a media release into the downloads folder and emits
`download.added` and `download.completed` naming the download's id, its category and the path of its
files: `nzbget`, `qbittorrent`, `youtube`.
- **engine** — a module that searches for media releases: the holder of `usenet-search` (`nzbgeek`,
ADR 0295) or of `youtube-search` (`youtube`, ADR 0296).
- **grab** — an engine handing one media release to a download client. It names the work it is for, so
the media-importer knows where the copy belongs.
## The record — how this repository works ## The record — how this repository works
Not a domain of the mesh but of the way it is built, listed because its words meet the mesh's. Not a domain of the mesh but of the way it is built, listed because its words meet the mesh's.
**Recorded in** this repository, the issue tracker and the knowledge base. **Decided by** ADR 0019, 0080, 0244, 0275, **Recorded in** this repository. **Decided by** ADR 0019, 0080, 0244.
0284, 0290, 0291.
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation, held whole in the - **research effort**, **graduation**, **hand-off**, **playbook** — an investigation in `01-RESEARCH/`;
knowledge base and named "research NNN";
its closing into a decision and a design; a design given to a code repository; a documented workflow its closing into a decision and a design; a design given to a code repository; a documented workflow
in `00-META/process/`. in `00-META/process/`.
- **decision record** — one numbered record in the knowledge base, named "ADR NNNN", never rewritten: - **decision record** — one numbered file in `02-DECISIONS/`, never rewritten: **superseded** by a later
**superseded** by a later record, or corrected by a marked **progressive insight**. Always qualified in this repository: bare record, or corrected by a marked **progressive insight**. Always qualified in this repository: bare
*record* has other meanings (see [Homonyms](#homonyms)). *record* has other meanings (see [Homonyms](#homonyms)).
- **clause** — one numbered statement a decision record makes, with its own status (`proposed`, `in force`,
`changed`, `superseded`): a later record may change one clause and leave the rest
(ADR 0290). Not a *rule*, which is a line of
`00-META/how-we-build.md`.
- **knowledge base** — the role that holds the decision records and research efforts whole (number, status, topic,
typed links, clauses and reasoning, with every version), takes them as proposals, has them reviewed and accepted,
and answers what holds: the seat `knowledge-base`, held by the module `mesh-knowledge`
([55 — The knowledge base](../03-DESIGN/01-to-be/55-the-knowledge-base.md)). Not **the record**, the literal reader
of this repository.
- **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built). - **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built).
- **issue** — a question about the mesh that someone must answer, numbered and tracked by the issue tracker, which - **issue** — a report in `04-ISSUES/` of something wrong with the mesh at the level of design or
holds it whole: its report, diagnosis and resolution included (ADR 0291); governance; an **incident** is a past event that taught a rule.
the folders in `04-ISSUES/` are imported there and removed.
An **incident** is a past event that taught a rule.
- **issue tracker** — the role that holds the mesh's issues and hands out their numbers: the seat `issue-tracker`,
held by the module `mesh-issues` ([53 — The issue tracker](../03-DESIGN/01-to-be/53-the-issue-tracker.md)).
Not hits's *item*; bare *ticket* is a homonym (see [Homonyms](#homonyms)).
*Not:* ~~ticketing system~~ (hq), ~~ticket system~~ (hq)
- **duplicate check** — the issue tracker's judgement, when an issue is opened, of whether it asks a question an
older issue not resolved already asks; a certain duplicate is closed `wontfix` with its report kept on the older
issue (ADR 0278).
Its pass over the backlog is the verb `groom`.
- **triage** — the issue tracker's judgement of an opened issue, made by the agent with no tools: whether it is a
duplicate (the **duplicate check**), its **severity** and what it **needs**, a suggested owner, and the issues it
depends on or relates to; it fills only what nobody gave (ADR 0279).
- **ready** — of an issue: every issue it depends on is wontfix, or resolved with its fix delivered.
- **worker** — of an issue: the agent or person, with its node (who@node), that moved it to `diagnosing` or `fixing`
and works on it; another is refused while the worker is current, unless it gives a reason to **take it over**. A
worker with nothing done on the issue for a day is **stale**. **Fixing** is the status an issue is at while its fix
is worked on; an issue is resolved only from it (ADR 0284).
Not a *claim* or a *holder*, which are a module's on a seat.
- **abandoned** — of an issue: at `diagnosing` or `fixing`, its worker stale and no pull request of it open; another
worker takes it over without a reason (ADR 0294).
Not a *stale claim*: a claim is a module's on a seat.
- **fix** — of an issue: a pull request its `fixed-by` names or it links; one closed without merging is
**withdrawn**. An issue at `fixing` with every fix delivered is **delivered, not resolved** (ADR 0294).
- **tracks** — of an issue and a condition: the issue links the condition's key with a link of kind `condition`. The
**tracking line** is the line the router says in every message about a condition: the issues that track it and
where each stands, or that none does (ADR 0285).
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check. - **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
## Homonyms ## Homonyms
@@ -664,7 +523,7 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|---|---|---|---| |---|---|---|---|
| plan | what the controller would send one node | that node's **declaration** (verb `plan`) | Placement | | plan | what the controller would send one node | that node's **declaration** (verb `plan`) | Placement |
| | what a delivery would do to the mesh | **delivery plan** | Change and delivery | | | what a delivery would do to the mesh | **delivery plan** | Change and delivery |
| | the sending of one batch of commits across nodes | **walk** (verb `plans`) | Change and delivery | | | the sending of one commit across nodes | **walk** (verb `plans`) | Change and delivery |
| | a step a person starts, like the bus's | **planned step** | Core | | | a step a person starts, like the bus's | **planned step** | Core |
| membership | the subjects a bus account is issued | **membership** | Provisioning | | membership | the subjects a bus account is issued | **membership** | Provisioning |
| | an account in a group of the machine | **account group** | Placement | | | an account in a group of the machine | **account group** | Placement |
@@ -673,7 +532,6 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| | a phone notification | **push notification** | Operator and conversation | | | a phone notification | **push notification** | Operator and conversation |
| release | a person letting a held delivery go on | **release** | Change and delivery | | release | a person letting a held delivery go on | **release** | Change and delivery |
| | taking a consumer off a licence | **unbind** (verb `release`, to rename) | Identity and access | | | taking a consumer off a licence | **unbind** (verb `release`, to rename) | Identity and access |
| | a copy of a work an engine can fetch | **media release** | Media |
| gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery | | gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery |
| | the pull request status | **merge gate** | Change and delivery | | | the pull request status | **merge gate** | Change and delivery |
| | a failed step stopping its module's later steps (ADR 0136) | *a failed step holds its module* | Placement | | | a failed step stopping its module's later steps (ADR 0136) | *a failed step holds its module* | Placement |
@@ -686,10 +544,8 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| | the OCI registry | **artifact store** | Change and delivery | | | the OCI registry | **artifact store** | Change and delivery |
| | a node's own copy of its last declaration | **last declaration** | Placement | | | a node's own copy of its last declaration | **last declaration** | Placement |
| record | a numbered decision | **decision record** | The record | | record | a numbered decision | **decision record** | The record |
| | the literal reader of this repository, searched first for a symptom | **the record** (the `records` module) | Operator and conversation | | | the knowledge base agents search first | **the record** (the `records` module) | Operator and conversation |
| | the upgrade policy that sends nowhere | `record` | Module | | | the upgrade policy that sends nowhere | `record` | Module |
| knowledge base | the module holding the decision records and research efforts whole | **knowledge base** | The record |
| | the predecessor's operational memory, in older playbooks (issue 430) | none: it no longer exists | — |
| check | a pull request's two statuses | **merge check**, **merge gate**, **repository check** | Change and delivery | | check | a pull request's two statuses | **merge check**, **merge gate**, **repository check** | Change and delivery |
| | a delivery group's verdict | **composed check** | Change and delivery | | | a delivery group's verdict | **composed check** | Change and delivery |
| | the controller's own examination | **self-check** | Health and repair | | | the controller's own examination | **self-check** | Health and repair |
@@ -700,8 +556,6 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| | a human | **operator** or **person** | words every domain uses | | | a human | **operator** or **person** | words every domain uses |
| firewall | what the mesh enforces | **packet filter** | Connectivity | | firewall | what the mesh enforces | **packet filter** | Connectivity |
| | what was found on a machine | **found firewall** | Connectivity | | | what was found on a machine | **found firewall** | Connectivity |
| ticket | an answer's one-time token (ADR 0258) | **answer ticket** | Operator and conversation |
| | a question someone must answer | **issue** | The record |
| deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery | | deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery |
| | a wrapped program's own deploy | its own word | — | | | a wrapped program's own deploy | its own word | — |
@@ -710,15 +564,14 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
A new word for an existing thing lands here first, in the domain that owns it, in the same change that A new word for an existing thing lands here first, in the domain that owns it, in the same change that
introduces it in code. A word moves to another domain only with a decision record. A retired word goes introduces it in code. A word moves to another domain only with a decision record. A retired word goes
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
decision record keeps whatever word it was written with — those are immutable — so a word record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a word
retired here may still appear in one, and the *Not:* lines are how to read it. retired here may still appear there, and the *Not:* lines are how to read it.
**How it is checked** (ADR 0244): **How it is checked** ([ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)):
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired `python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
issues opened from 2026-10-07 — code spans, quotations and link targets excepted — and on a research and issues dated from 2026-10-07 — code spans, quotations and link targets excepted — and on a
head word that heads two entries or is also retired. The words retired with no scope or with *(tools)* head word that heads two entries or is also retired. The words retired with no scope or with *(tools)*
are copied into the catalogue as `retired-words`, whose own repository check holds the descriptions of are copied into the catalogue as `retired-words`, whose own repository check holds the descriptions of
the mesh's tools to them; a change here that retires such a word changes that copy in the same delivery. the mesh's tools to them; a change here that retires such a word changes that copy in the same delivery.
A decision record or research effort proposed to the knowledge base is held to the same words by its Homonyms are checked by review.
rules `retired-word` and `daemon-line`, which read this page (ADR 0290). Homonyms are checked by review.
+17 -17
View File
@@ -1,9 +1,9 @@
--- ---
status: canonical status: canonical
updated: 2026-10-11 updated: 2026-08-23
derives: the constitution page derives: knowledge-base constitution page
decisions: decisions:
- "ADR 0020" - 02-DECISIONS/0020-the-mesh-is-governed-by-a-constitution.md
--- ---
# How we build # How we build
@@ -37,13 +37,13 @@ incident behind it is not written down, and the fix is to write it down, not to
| Rule | What it means | | Rule | What it means |
|---|---| |---|---|
| **Never write to a production database directly** | No insert, update, delete or schema statement executed against production by hand. Schema changes go through numbered migrations; data changes go through application code or the module's own capabilities. Raw statements skip every side effect the proper path has — events, audit, cache invalidation, fan-out. | | **Never write to a production database directly** | No insert, update, delete or schema statement executed against production by hand. Schema changes go through numbered migrations; data changes go through application code or the module's own capabilities. Raw statements skip every side effect the proper path has — events, audit, cache invalidation, fan-out. |
| **Every schema change is a migration** | Numbered, in the module's own language, compiled with it. Both a baseline for a fresh installation *and* an incremental migration for installations that already exist. If code references a column, the migration creating it must exist. ADR 0013 | | **Every schema change is a migration** | Numbered, in the module's own language, compiled with it. Both a baseline for a fresh installation *and* an incremental migration for installations that already exist. If code references a column, the migration creating it must exist. [ADR 0013](../02-DECISIONS/0013-schema-changes-are-numbered-migrations.md) |
| **Never bypass the pipeline** | No manual database edit, no manual restart as a workaround. Fix the cause and deploy. A workaround that works is a workaround that is never removed, and the next person cannot tell the node from its declaration. | | **Never bypass the pipeline** | No manual database edit, no manual restart as a workaround. Fix the cause and deploy. A workaround that works is a workaround that is never removed, and the next person cannot tell the node from its declaration. |
| **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. **The mesh creates none at all** (ADR 0012, which supersedes ADR 0012). The links the installer still reconciles are a migration, not a permission. | | **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. **The mesh creates none at all** ([ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md), which supersedes [ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md)). The links the installer still reconciles are a migration, not a permission. |
| **Never push directly to the main branch** | Branch, push, review, merge. Every merge is a human checkpoint, without exception — **including in this repository**. A documentation repository is not a lower tier of care; a decision record lands the same way a service does. | | **Never push directly to the main branch** | Branch, push, review, merge. Every merge is a human checkpoint, without exception — **including in this repository**. A documentation repository is not a lower tier of care; a decision record lands the same way a service does. |
| **One change per pull request, and never merge unapproved work** | Unrelated improvements bundled together cannot be reviewed or reverted separately. And the checkpoint is **a person deciding, not a person clicking** — work may be merged by whoever wrote it once a human has explicitly approved *that merge*, and never on a standing permission, an instruction to do the work, silence, or the author's own judgement that it is ready. ADR 0023 | | **One change per pull request, and never merge unapproved work** | Unrelated improvements bundled together cannot be reviewed or reverted separately. And the checkpoint is **a person deciding, not a person clicking** — work may be merged by whoever wrote it once a human has explicitly approved *that merge*, and never on a standing permission, an instruction to do the work, silence, or the author's own judgement that it is ready. [ADR 0023](../02-DECISIONS/0023-approval-is-the-checkpoint.md) |
| **Never open a pull request unprompted** | A permissions list saying it is allowed is not a request. | | **Never open a pull request unprompted** | A permissions list saying it is allowed is not a request. |
| **A failed step fails the job** | A sequence that continues past a failure does the next thing in the wrong place. Gate each step on the last. ADR 0010, and §5. | | **A failed step fails the job** | A sequence that continues past a failure does the next thing in the wrong place. Gate each step on the last. [ADR 0010](../02-DECISIONS/0010-delivery.md), and §5. |
### A failed step must stop the steps after it — how it was earned ### A failed step must stop the steps after it — how it was earned
@@ -69,7 +69,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
- **Every runtime variable is declared.** A variable the module reads and the manifest does not - **Every runtime variable is declared.** A variable the module reads and the manifest does not
declare is invisible to the mesh: it will not be generated, injected, or audited. declare is invisible to the mesh: it will not be generated, injected, or audited.
- **Provisioned credentials arrive through declared requirements**, never hardcoded in code, - **Provisioned credentials arrive through declared requirements**, never hardcoded in code,
compose files or scripts. ADR 0009 compose files or scripts. [ADR 0009](../02-DECISIONS/0009-modules-and-the-graph.md)
- **Never install a package by hand.** A package is declared in the manifest and arrives the - **Never install a package by hand.** A package is declared in the manifest and arrives the
way every other package does. A hand-installed package is invisible to the mesh: it is not way every other package does. A hand-installed package is invisible to the mesh: it is not
declared, not reproduced on the next node, and not present after a rebuild — and the node declared, not reproduced on the next node, and not present after a rebuild — and the node
@@ -82,7 +82,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
- **Every standalone application gets its own repository**, with a manifest at its root, - **Every standalone application gets its own repository**, with a manifest at its root,
registered as a build source. Creating an application directory in the monorepo is a registered as a build source. Creating an application directory in the monorepo is a
convention violation and reviewers reject it. convention violation and reviewers reject it.
ADR 0015 [ADR 0015](../02-DECISIONS/0015-applications-live-in-their-own-repository.md)
### Migrations ### Migrations
@@ -96,7 +96,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
surface is regenerated from the mesh database; a local edit survives one synchronisation and is surface is regenerated from the mesh database; a local edit survives one synchronisation and is
then silently overwritten, bringing back whatever it fixed. Use the mesh operation that owns then silently overwritten, bringing back whatever it fixed. Use the mesh operation that owns
the value. If unsure whether a file is managed, ask the tooling — the answer is not visible the value. If unsure whether a file is managed, ask the tooling — the answer is not visible
from the file. ADR 0011 from the file. [ADR 0011](../02-DECISIONS/0011-managed-files-are-generated-never-edited.md)
--- ---
@@ -121,7 +121,7 @@ for them. Do not merge them into one module: they are delivered to different nod
that must be assigned where half of it is unwanted is not a boundary either. that must be assigned where half of it is unwanted is not a boundary either.
Coherence is a context. Delivery is a module. Relationships are edges, not folders. Coherence is a context. Delivery is a module. Relationships are edges, not folders.
ADR 0009 [ADR 0009](../02-DECISIONS/0009-modules-and-the-graph.md)
### Contexts integrate through the record, never through a shared schema ### Contexts integrate through the record, never through a shared schema
@@ -198,8 +198,8 @@ declaration was never wrong. Nothing ever asked the system.
### Search the record before forming a hypothesis ### Search the record before forming a hypothesis
The first action on any error message, failing service or unexpected behaviour is to search the The first action on any error message, failing service or unexpected behaviour is to search the
record for the literal error text (`records.records_search`) — before a hypothesis, not after one fails. operational memory for the literal error text — before a hypothesis, not after one fails. The
The search matches the words as written, so search with the error's own words. knowledge base is indexed on symptoms.
This fires hardest on *familiar* ground, where a confident trail feels like progress. Two This fires hardest on *familiar* ground, where a confident trail feels like progress. Two
entries were each rediscovered from scratch over several hours in a single session because the entries were each rediscovered from scratch over several hours in a single session because the
@@ -214,7 +214,7 @@ This document is governed. It does not change by commit message or unilateral de
1. **Propose** — a change stating what rule is changing, why the current wording is 1. **Propose** — a change stating what rule is changing, why the current wording is
inadequate, and what reviewed it. inadequate, and what reviewed it.
2. **Review** — sign-off by reviewers who are not the proposer. 2. **Review** — sign-off by reviewers who are not the proposer.
3. **Record** — the change is a decision and gets a decision record in the knowledge base (playbook [02](process/02-graduation.md)), because a 3. **Record** — the change is a decision and gets a record in [`02-DECISIONS/`](../02-DECISIONS/), because a
rule the mesh enforces is architecturally significant. rule the mesh enforces is architecturally significant.
4. **Sync** — playbook [`process/05-constitution-sync.md`](process/05-constitution-sync.md) 4. **Sync** — playbook [`process/05-constitution-sync.md`](process/05-constitution-sync.md)
publishes the derived page. An unsynced rule is a rule the mesh does not enforce, whatever publishes the derived page. An unsynced rule is a rule the mesh does not enforce, whatever
@@ -226,7 +226,7 @@ No drive-by edits. Every change traces to a recorded decision.
a design meeting with at least two node operators — which has never been met and cannot be, as a design meeting with at least two node operators — which has never been met and cannot be, as
there is one operator. A rule that cannot be satisfied is not a high standard; it is a rule there is one operator. A rule that cannot be satisfied is not a high standard; it is a rule
everything silently violates. Recorded here as resolved in favour of what is achievable, and everything silently violates. Recorded here as resolved in favour of what is achievable, and
what has in fact been practised (ADR 0022). what has in fact been practised ([ADR 0022](../02-DECISIONS/0022-the-constitution-absorbs-what-is-enforced.md)).
--- ---
@@ -243,7 +243,7 @@ process. Absence of an override means these rules apply unmodified.
## 8. Code quality ## 8. Code quality
*Absorbed 2026-08-26 from the enforced page, which carried these rules while this document did *Absorbed 2026-08-26 from the enforced page, which carried these rules while this document did
not — ADR 0022.* not — [ADR 0022](../02-DECISIONS/0022-the-constitution-absorbs-what-is-enforced.md).*
**These rules are recorded because they are enforced, not because this repository earned them.** **These rules are recorded because they are enforced, not because this repository earned them.**
Every other rule here states the incident or measurement behind it. These state nothing, Every other rule here states the incident or measurement behind it. These state nothing,
@@ -272,7 +272,7 @@ Data access, business logic and the interface layer are separate.
*Scope: the mesh's services and surfaces. Tier 0 is a statically linked binary that must depend *Scope: the mesh's services and surfaces. Tier 0 is a statically linked binary that must depend
on nothing installed first, and is written in Go — on nothing installed first, and is written in Go —
ADR 0005.* [ADR 0005](../02-DECISIONS/0005-the-node-host.md).*
- TypeScript throughout; no new untyped JavaScript. - TypeScript throughout; no new untyped JavaScript.
- Strict, with no implicit `any` and no unchecked index access. - Strict, with no implicit `any` and no unchecked index access.
+16 -24
View File
@@ -3,8 +3,8 @@
How work moves through HQ, and who may do what. In industry terms this is **spec-driven How work moves through HQ, and who may do what. In industry terms this is **spec-driven
development, with provenance**: the decision is the why, the design doc is the spec, `code:` development, with provenance**: the decision is the why, the design doc is the spec, `code:`
names the implementation, and the lab beds are the conformance tests — and unlike the common names the implementation, and the lab beds are the conformance tests — and unlike the common
form, the chain itself is checked (ADR 0080, form, the chain itself is checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md),
ADR 0081). Every other document in this folder is a [0081](../../02-DECISIONS/0081-a-decision-nothing-cites-is-not-yet-in-the-chain.md)). Every other document in this folder is a
playbook: trigger, who runs it, steps, outputs. Engineers and agents follow the same playbook: trigger, who runs it, steps, outputs. Engineers and agents follow the same
playbooks; agents must not act outside them. playbooks; agents must not act outside them.
@@ -19,13 +19,11 @@ playbooks; agents must not act outside them.
## The knowledge flow ## The knowledge flow
``` ```
idea ──► research ──► decision ──► 03-DESIGN/01-to-be ──► built (code repo) idea ──► 01-RESEARCH ──► decision (02-DECISIONS/) ──► 03-DESIGN/01-to-be ──► built (code repo)
│ │ │ │ │ │
│ │ └─► 03-DESIGN/00-as-is once shipped │ │ └─► 03-DESIGN/00-as-is once shipped
│ └────► abandoned (recorded, kept) │ └────► abandoned (recorded, kept)
└─(small/obvious, still a decision record)──────────► 03-DESIGN directly └─(small/obvious, still recorded in 02-DECISIONS)──────────► 03-DESIGN directly
research and decisions: in the knowledge base (knowledge-base.reserve, propose, ask, get, holds)
symptom ──► 04-ISSUES ──► diagnosis ──► code-repo fix and/or design amendment symptom ──► 04-ISSUES ──► diagnosis ──► code-repo fix and/or design amendment
@@ -39,7 +37,7 @@ how-we-build.md ──► constitution sync ──► knowledge base ──► i
| Layer | What it is | Changes when | | Layer | What it is | Changes when |
|---|---|---| |---|---|---|
| `00-as-is/` | The mesh that exists today. Describes shipped behaviour, including behaviour nobody would choose again. | Something ships, or an as-is claim is found to be wrong. | | `00-as-is/` | The mesh that exists today. Describes shipped behaviour, including behaviour nobody would choose again. | Something ships, or an as-is claim is found to be wrong. |
| `01-to-be/` | The mesh being built toward. Every statement traceable to a decision record in the knowledge base. | A decision is taken or amended. | | `01-to-be/` | The mesh being built toward. Every statement traceable to a record in `02-DECISIONS/`. | A decision is taken or amended. |
A to-be document that ships does not move. Its as-is counterpart is written or updated, the A to-be document that ships does not move. Its as-is counterpart is written or updated, the
to-be document's frontmatter goes to `implemented`, and both stand — one describing what runs, to-be document's frontmatter goes to `implemented`, and both stand — one describing what runs,
@@ -62,25 +60,19 @@ the expensive half.
The flow above is a rule, and a rule states how it is checked: The flow above is a rule, and a rule states how it is checked:
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no [`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
decision, an `in-progress`/`implemented` design that names no owning code, two issue folders decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
sharing a number or an issue report carrying a status key; a `graduated` research effort that `located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with `records.py`, `index.py` and research overview that does not say what it became. Run it with `records.py`, `index.py` and
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). The issue rules the check `words.py` before any HQ merge (`sh merge-check.sh` runs all four). What the checks cannot see — that code work actually started from a
once held — `located` with an owner, `resolved` with a fix, a core issue with its replay — are
refused at the write by the issue tracker's `move`
(ADR 0275). What the checks cannot see — that code work actually started from a
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md): handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
a feature branch exists because a design or an issue sent it. a feature branch exists because a design or an issue sent it.
## Status lives in frontmatter, an issue's in the tracker ## Status lives in frontmatter
Design docs carry their status as YAML frontmatter (schema in playbook [02](02-graduation.md)). Research overviews, design docs, issue reports and decision records each carry their status as
Research efforts and decision records carry theirs in the knowledge base, which refuses a wrong one at YAML frontmatter (schemas in the section READMEs and playbooks). There are **no central status
`propose` (ADR 0290). An issue's status lives in the
issue tracker, which refuses a wrong status at the write, and its report here carries only
the day it was opened (playbook [03](03-issues.md)). There are **no central status
files** and no decision ledger. **Every decision is a record in files** and no decision ledger. **Every decision is a record in
the knowledge base** — if it is worth recording it is worth a record, and if [`02-DECISIONS`](../../02-DECISIONS/)** — if it is worth recording it is worth a record, and if
it is not worth a record it is not recorded. Cross-cutting views are generated on demand by the it is not worth a record it is not recorded. Cross-cutting views, the decision index included,
`hq-status` skill and never written to disk (ADR 0248); the decisions of a topic are are generated on demand by the `hq-status` skill and never written to disk
`knowledge-base.holds` with that topic. ([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)).
+19 -37
View File
@@ -5,34 +5,25 @@ design. Also: an as-is document that raises a question nobody can answer.
**Who runs it.** Anyone. **Who runs it.** Anyone.
**Where it lives.** A research effort lives whole in the knowledge base, the seat `knowledge-base`, reached
through the mesh MCP server from any node and any folder (ADR 0290). This repository holds none: an effort
is written with the knowledge base's verbs, never as a folder here.
## Steps ## Steps
1. **Read what is known first.** `knowledge-base.ask` with the question in plain words returns the decision 1. Take the next free number: highest `01-RESEARCH/NNN-*` plus one, zero-padded to three
records and research efforts that bear on it; `knowledge-base.get` with `kind: research` and a number digits. Never skip or reuse a number.
reads one effort, and `reasoning: true` (or `section` with a document's name) reads its documents. 2. Create `01-RESEARCH/NNN-descriptive-name/` and a `00-overview.md` in it carrying:
2. **Take a number.** `knowledge-base.reserve` with `kind: research` and `for`: the effort's title. The
number comes from the issue tracker's counter and is spent whether or not the effort is ever proposed;
one never proposed is withdrawn after a week. Never pick a number by hand.
3. **Propose it.** `knowledge-base.propose` with `kind: research`, the `number` reserve gave, a one-line
`title`, `status: active`, `date` (the day it began), and `sections`: its documents in order, each a
`name` and its markdown `text`. The first is `00-overview.md`: **what** is being investigated, **why**,
and **what it touches**. Name what it touches as `links` of kind `touches` (`"0276"`, `"research 045"`,
`"issue 365"`, or a design's path), not only in prose, and what it names for another reason as `cites`.
4. **Do the research** in further documents of the same effort: notes, option analyses, evidence, draft
designs. Each change is `knowledge-base.propose` again with the effort whole and `why`, one line saying
why this version. Every version is kept; `knowledge-base.history` lists them and gives the difference
between two.
`propose` refuses an effort that breaks a rule that judges one record (an unknown status, a link to ```yaml
nothing, a retired word of the glossary, a daemon's line), naming every reason at once and writing nothing. ---
The knowledge base then reviews the proposal itself, through a judgement that sees only the record, the status: active
records it links and the rules; on `changes` the effort stays proposed with the findings, and its author initiated: YYYY-MM-DD
proposes it again. An effort that needs the operator's word is proposed with `needs-operator: true` and touches: [] # areas, subsystems or as-is design docs the effort bears on
waits for the operator's Approve on a channel that proves who answered. ---
```
Then a short prose summary: **what** is being investigated, **why**, and **what it
touches**.
3. Do the research in further documents in the same folder — notes, option analyses, evidence,
draft designs. Anything goes. Keep the summary in `00-overview.md` current as the effort changes
shape.
## What makes research worth reading ## What makes research worth reading
@@ -46,21 +37,12 @@ carries the whole lesson without naming anything.
## Do not ## Do not
- Do not put status in prose. It is the effort's `status`, and the prose must not restate it. - Do not put status in prose. It lives in `00-overview.md`'s frontmatter, and the prose must not restate it.
- Do not give a `became` link while the effort is open — playbook 02 gives it at closure. - Do not set `became:` while the effort is open — playbook 02 sets it at closure.
- Do not write into `03-DESIGN` from an open effort. - Do not write into `03-DESIGN` from an open effort.
- Do not write an effort, or a copy of one, into this repository: the knowledge base is the only place one
is read, and `records.py` refuses a new path under `01-RESEARCH/`.
## Closing ## Closing
An effort never just stops. It closes through playbook [02](02-graduation.md) as `graduated` An effort never just stops. It closes through playbook [02](02-graduation.md) as `graduated`
or `abandoned`, always with a `became` link to what it turned into. Nothing is deleted — or `abandoned`, always with `became:` pointing at what it turned into. Nothing is deleted —
what was rejected, and why, is the more expensive half to rediscover. what was rejected, and why, is the more expensive half to rediscover.
## How it is checked
The rules on an effort are the knowledge base's: `propose` refuses one that breaks a rule that judges one
record (the module's table test holds one case per rule), and the rules that judge the whole record set run
after every acceptance and raise a condition when one fails. That this repository holds no effort is
`records.py`'s `no-records` check, on every pull request.
+14 -89
View File
@@ -4,20 +4,15 @@
**Who runs it.** Anyone, with the decision recorded before the design moves. **Who runs it.** Anyone, with the decision recorded before the design moves.
**Where a decision lives.** A decision record lives whole in the knowledge base, the seat `knowledge-base`,
reached through the mesh MCP server from any node and any folder (ADR 0290): its number, title, topic,
status, date, deciders, its clauses, its typed links, its reasoning and every version. This repository holds
none, and keeps no copy. What was decided is read with `knowledge-base.ask` (a question in plain words),
`knowledge-base.get` (one record by number; `reasoning: true` for its why) and `knowledge-base.holds` (the
clauses in force for a topic or a record).
## Graduating research ## Graduating research
1. **Check it against GENESIS.** An effort graduates only if its conclusion is traceable to 1. **Check it against GENESIS.** An effort graduates only if its conclusion is traceable to
[`mission.md`](../mission.md), [`context.md`](../context.md) and [`mission.md`](../mission.md), [`context.md`](../context.md) and
[`effect.md`](../effect.md). If it conflicts, either the effort is wrong or GENESIS is — [`effect.md`](../effect.md). If it conflicts, either the effort is wrong or GENESIS is —
say which, in writing, before proceeding. say which, in writing, before proceeding.
2. **Record the decision** (below, *Writing a decision record*). 2. **Record the decision.** Write a record in [`02-DECISIONS/`](../../02-DECISIONS/) taking the next free
number. Format and rules are in [`02-DECISIONS/README.md`](../../02-DECISIONS/README.md). State evidence,
not assertion, and record the options that were rejected — that is the half worth keeping.
3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter: 3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter:
```yaml ```yaml
@@ -26,83 +21,25 @@ clauses in force for a topic or a record).
status: designed status: designed
code: [] # owning code repo(s); set at build handoff, empty before code: [] # owning code repo(s); set at build handoff, empty before
updated: YYYY-MM-DD updated: YYYY-MM-DD
decisions: decisions: [02-DECISIONS/NNNN-....md]
- "ADR NNNN"
--- ---
``` ```
4. **Close the effort.** `knowledge-base.propose` the effort again, whole, with `status: graduated` and a 4. **Close the effort.** Set the effort's `00-overview.md` frontmatter to `status: graduated` and
`became` link to the decision record (`"NNNN"`) and to the design document (its path). An effort that `became:` pointing at the design document and the decision record.
stops without a decision is proposed with `status: abandoned` and a `became` link to what replaced it.
## Writing a decision record
1. **Take a number.** `knowledge-base.reserve` with `kind: decision` and `for`: the record's title. The
number comes from the issue tracker's counter and is spent whether or not the record is accepted; one
never proposed is withdrawn after a week. Never pick a number by hand.
2. **Propose it.** `knowledge-base.propose` with the `number`, a one-line `title` in plain language, its
`topic` (one of the six of the reading order, [`02-DECISIONS/README.md`](../../02-DECISIONS/README.md)),
`date` (when the decision was taken, not when it was written down), `deciders`, and:
- `clauses`: what was decided, in order, each one statement opening with a bold lead
(`"**What is decided.** …"`);
- `sections`: the reasoning, in order: `Context` (what was true, with evidence), `Considered Options`
(numbered, each with why it was rejected), `Consequences` (what follows, including what got harder),
`How it is checked` (a rule states how it is checked), `References`;
- `links`: `extends` for a record it builds on, `rests-on` for a decision it stands on, `supersedes` for one it replaces whole, `changes` with
the clause it replaces part of (`"0276 §4"`, and `from` for the clause of this record that does it),
`cites` for any record, effort, issue or design it names;
- `homes`: the documents of this repository that cite it, such as the design's path;
- `needs-operator: true` when it holds only once the operator approves it.
State evidence, not assertion. *"Zero of 124 modules declare `brain` as a dependency"* outranks *"the
dependency rule is not followed"*. Record the options that were rejected — that is the half worth keeping.
3. **A record that puts work off** — a rename, a check, a case it does not handle — names the issue that
tracks it, in the sentence that defers it ("tracked by issue N"), or the deferral is not accepted: a
deferral nothing tracks reads as done
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md),
ADR 0244's code renames). `propose` refuses it (rule `deferral`).
4. **Review and acceptance.** `propose` refuses, naming every reason at once and writing nothing, a record
that breaks one of the rules that judge one record: a link to nothing, a clause without a bold lead, a
superseded record cited alone, an unknown topic, a retired word of the glossary, a daemon's line, a
deferral no issue tracks. The knowledge base then reviews the proposal itself, through a judgement that
sees only the record, the records it links and the rules, and answers `approve` or `changes`. On
`changes` the record stays proposed with the findings: revise it by proposing it again, whole, with `why`.
On `approve` it is accepted, or, when it says `needs-operator`, it waits for the operator's Approve on a
channel that proves who answered. Nothing proposed is in force. `knowledge-base.get` shows a record's
reviews and the operator's ask when it waits for one.
## Changing a decision
**A record's meaning is never rewritten.** A decision is changed by a new record, and a correction of fact
by a progressive insight.
- **Reversing a decision, changing its scope, rejecting an option it accepted, or making a consequence false
that a later record cites** is a new record that `supersedes` the old one whole, or `changes` the clauses
it replaces. On its acceptance the old record or clauses become `superseded` or `changed`, and every
answer says by what. The test is not how large the edit looks; it is whether a reader who acted on the
old text would now be wrong about *what was decided* rather than about *a detail the decision did not
rest on*.
- **A progressive insight** corrects a fact a record asserted that turned out wrong while the decision it
supports stays right: a count taken before anyone measured, a file named that does not exist. It is
`knowledge-base.propose` of the accepted record with `insight`: what was found, what the record said
before, and what it says instead. It is a new version, marked as one, dated, and reviewed like a
proposal; it adds and never quietly replaces; the decision, the options weighed and the consequences
stand untouched. If the correction changes what was decided, it is not an insight — write the new record.
- **Where a later record changes the mechanism an older one states** without reversing it, the later
record `changes` the older one's clause, so a reader who arrives at the older record by a citation sees
what changed it since.
## Amending an existing design ## Amending an existing design
A design changes only through a decision. A design changes only through a decision.
1. Write the decision record (above). If it reverses an earlier one, it `supersedes` it. 1. Write the decision record. If it reverses an earlier one, the earlier record's `status:`
2. Edit the to-be design document, add the record to its `decisions:` as `"ADR NNNN"`, and set `updated:` becomes `superseded-by: 02-DECISIONS/NNNN-....md` — **its reasoning is never rewritten**. If the
to today. earlier record is sound and only a *fact* in it went stale, that is a **progressive insight**,
3. If the amendment came from an issue, give the issue the document path in the issue tracker: corrected in place and marked in the record rather than superseded
`amended-design` on its `issue-tracker.move` to `resolved`, or `issue-tracker.link` with kind ([`02-DECISIONS/README.md`](../../02-DECISIONS/README.md)).
`design` before then — never in the report's frontmatter 2. Edit the to-be design document and set `updated:` to today.
(ADR 0275). 3. If the amendment came from an issue, set that issue's `amended-design:` to the document
path.
## When something ships ## When something ships
@@ -122,15 +59,3 @@ Implementation state is a third axis, independent of both design and decision.
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for. - Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while - Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
the decision stands, is a progressive insight — marked and dated in the record, never silent. the decision stands, is a progressive insight — marked and dated in the record, never silent.
- Do not write a decision record, or a copy of one, into this repository: `records.py` refuses a new path
under `02-DECISIONS/` other than its README.
## How it is checked
The rules on a record are the knowledge base's: `propose` refuses one that breaks a rule that judges one
record (its module's table test holds one case per rule), and the four rules that judge the whole record set
(an accepted decision cited by nothing, a supersession named by one side only, a changed clause nothing
changes, an accepted record extending a superseded one) run after every acceptance and raise a condition
when one fails. In this repository, `records.py` checks that a design's `decisions:` entries and every
"ADR NNNN" in the text are well formed, and that no record is written here; `index.py` checks the reading
order's six topics, which the knowledge base reads.
+27 -85
View File
@@ -8,115 +8,57 @@ design permits.
## What belongs here, and what does not ## What belongs here, and what does not
| Belongs in `04-ISSUES` | Belongs with the fix's own lesson, not here | | Belongs in `04-ISSUES` | Belongs in the knowledge base |
|---|---| |---|---|
| The design permits a failure to be silent | How to fix one occurrence of it | | The design permits a failure to be silent | How to fix one occurrence of it |
| A documented rule is enforced by nothing | A command that works around it | | A documented rule is enforced by nothing | A command that works around it |
| A stated invariant is false in practice | A node-specific quirk | | A stated invariant is false in practice | A node-specific quirk |
| The owning component is unknown and finding it needs the whole mesh in view | Symptom → fix, once the answer is known | | The owning component is unknown and finding it needs the whole mesh in view | Symptom → fix, once the answer is known |
A fix's lesson (symptom → fix) is written where a search by the symptom's literal words finds it The knowledge base already holds the operational record and is indexed on symptoms. This
(`records.records_search`). This folder is not a second copy of it. An issue here is a question HQ must **answer**, not an folder is not a second copy of it. An issue here is a question HQ must **answer**, not an
incident someone must **clear**. incident someone must **clear**.
## The issue tracker holds the number and the status
Every issue is opened in the **issue tracker**, the module `mesh-issues` holding the seat `issue-tracker` on the
control-node (ADR 0275,
[to-be 53](../../03-DESIGN/01-to-be/53-the-issue-tracker.md)). Its number comes from one counter advanced by
compare-and-set, so two authors opening at the same moment never get the same one. **A number is never taken by
reading `main` or the open pull requests** — that is how numbers collided (issue 155: two records sharing a number
reached `main` with every check passing). A session that cannot reach the tracker takes no number and says so.
**The tracker is the truth for every issue's status, owner, fix and links**, whether or not the issue has a folder
here. `issue-tracker.get NNN` says where an issue stands; `issue-tracker.list` says what is open. Nothing here
says it: a report's frontmatter carries no status.
An issue takes one of two shapes:
- **With a folder here** — a design or governance issue, whose narrative belongs in this public record and is
reviewed in a pull request. The folder holds the report and the diagnosis; the tracker holds everything else.
- **Wholly in the tracker** — every other issue. Nothing is written here.
**`04-ISSUES/` stays, for the narrative only.** It keeps the public narrative of a design or governance issue: the
symptom as observed, the diagnosis, what was ruled out, and why. A plain bug — a defect whose fix needs no design
question answered — needs no folder; it is whole in the tracker.
**The reports written before the tracker** (the folders numbered 001 to 364) carried `status:`, `located-in:`,
`fixed-by:`, `amended-design:`, `replay:` and `replay-none:` in their frontmatter until the import of slice 3 had
taken them into the tracker and been checked against it, field by field; then one pull request removed them, so
that no status is held in two places. What they said that day is in this repository's history at the commit the
import read, which every imported issue names (`imported.commit` in `issue-tracker.get`).
## Steps ## Steps
1. Open the issue in the tracker, through the mesh MCP server: 1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
`mesh_call issue-tracker.open` with `title`, `report` (the symptom **as observed**, in plain terms, with the Work sits on unmerged branches for days, so two people both reading `main` allocate the same
evidence that it happened), optionally `located-in` (entries of the tracker's registered list of owners), and, for number; it happened twice in one hour between two machines, and the second collision reached
an issue that gets a folder here, `record` — the folder's name without its number, `short-name`. The tracker `main` with every check passing (issue 155). `cycle.py` now refuses two records sharing a number,
answers with the number `NNN`; a refused open spends none. which catches a collision but does not prevent one. Create
`04-ISSUES/NNN-short-name/00-report.md`:
**An issue with a folder** then creates `04-ISSUES/NNN-short-name/00-report.md` under exactly that number, on a
branch:
```yaml ```yaml
--- ---
status: open
opened: YYYY-MM-DD opened: YYYY-MM-DD
located-in: [] # owning repo(s)/module(s), filled by diagnosis
fixed-by: # PR or commit reference, filled at resolution
amended-design: # design doc path, when the root cause was a design gap
--- ---
``` ```
Then `# NNN. The title`, the title as the tracker holds it, and the symptom as observed, as in the tracker's Then the symptom **as observed**, in plain terms, with the evidence that it happened.
report. **No status key**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`, 2. Investigate in `01-diagnosis.md` in the same folder — the trail, dated, including what was
`replay:` or `replay-none:` in any report, because the tracker holds them and two places holding one fact ruled out. Move `status:` to `diagnosing`, then `located` once the owner is known.
drift. 3. Resolve. Set `status: resolved`, fill `fixed-by:`, and if the root cause was a design gap,
run playbook [02](02-graduation.md) and fill `amended-design:`. **A core issue** — its `located-in`
**An issue about a condition** links the condition's key at once: `issue-tracker.link` with kind `condition` and
the key `mesh-controller.conditions` gives. Every message about that condition then names the issue and where it
stands; one no issue links says "No issue tracks this yet." An agent that silences a condition because an issue
tracks it links that issue first (ADR 0285).
**An issue wholly in the tracker** is done at this step.
2. Investigate. **Before you start, move the issue to `diagnosing` with `by`**: who works on it and its node, as
who@node (`fix-399@laptop`, `operator@workstation`). The tracker keeps you as the issue's **worker**, and `get` and `list`
show it with since when. The trail goes in `issue-tracker.note`, and, for a folder-backed issue, in
`01-diagnosis.md` in the same folder as well — dated, including what was ruled out. A note keeps you the worker; a
worker with nothing done on the issue for a day is stale. Then move it to `located` with `located-in`, which the
tracker refuses without a registered owner; that ends your work on it.
3. Fix. **Before you start the fix, move the issue to `fixing` with `by`**, even when you diagnosed it yourself. The
tracker refuses the move while another worker is current, naming them: ask them, or take it over with
`take-over` saying why, which is noted on the issue. A stale worker may be taken over from without a reason.
Name the fix in `fixed-by` when it merges, with a move to `fixing` that gives `by` again; the issue stays
`fixing` until the acceptance is proven live
(ADR 0284,
ADR 0281).
4. Resolve. `issue-tracker.move` to `resolved`, which the tracker takes only from `fixing`, with `fixed-by`, and, if the root cause was a design gap,
run playbook [02](02-graduation.md) and give `amended-design`. **A core issue** — its `located-in`
names the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build names the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build
agent — resolves with `replay`, the id of its replay in mesh-lab's replays register, proved to fail on agent — resolves with `replay:`, the id of its replay in mesh-lab's replays register, proved to fail on
the commit before the fix and pass on it, or with `replay-none` saying why none is possible the commit before the fix and pass on it, or with `replay-none:` saying why none is possible
(ADR 0237); ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md); `cycle.py` checks it).
`issue-tracker.move` refuses the move without it. `wontfix` needs a `reason`.
## Rules ## Rules
- Closed issues are never deleted — they are the mesh's symptom-to-component memory. - Closed issues are never deleted — they are the mesh's symptom-to-component memory.
- `fixed-by` names something that will still exist: a commit or a pull request, never a branch. A - `fixed-by:` names something that will still exist: a commit or a pull request, never a branch. A
branch is deleted when it merges, so a branch name there is a pointer that resolves to nothing by branch is deleted when it merges, so a branch name there is a pointer that resolves to nothing by
the time anybody follows it. the time anybody follows it.
- A fix that turns out to have broken something else is written back into the record that asked for - A fix that turns out to have broken something else is written back into the record that asked for
it, pointing at the new issue. Somebody arriving at a record to learn why the code is the way it it, pointing at the new issue. Somebody arriving at a record to learn why the code is the way it
is must not have to already know there was a sequel. is must not have to already know there was a sequel.
- A number never moves. A folder carries the number the tracker gave; a folder whose number collides with - Renumbering a collision happens once, in the branch that lands last. Renumbering a branch whose
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records author is still pushing only moves the race.
sharing a number. - An issue whose answer is a general lesson should also be written to the knowledge base, so
- An issue whose answer is a general lesson should also have that lesson written where a search by the next person searching a symptom finds it. Both, not either.
the symptom's literal words finds it, so the next person searching it finds it. Both, not either. - `status: wontfix` is legitimate and requires a sentence saying why.
- `wontfix` is legitimate and requires a sentence saying why; the tracker refuses it without one.
- **An agent says when it starts work on an issue**, by moving it to `diagnosing` or `fixing` with `by`. Two agents
never start the same work without knowing: the second is refused, naming the first.
- **A rule states how it is checked.** The cycle's rules on an issue — a status one of the six, `diagnosing` and
`fixing` with a worker and no other current one, `located` with an owner, `resolved` only from `fixing` and with a
fix, a core issue with its replay, `wontfix` with a reason — are refused at the write by
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). `cycle.py` checks what
this repository still holds: no two folders share a number, every report has frontmatter with the day it was
`opened:`, and no report carries a status key.
+1 -6
View File
@@ -7,8 +7,7 @@
## Steps ## Steps
1. **Confirm the design is settled.** Its frontmatter reads `status: designed`, and every 1. **Confirm the design is settled.** Its frontmatter reads `status: designed`, and every
claim in it traces to a decision record in the knowledge base (`knowledge-base.get` for each entry of claim in it traces to a record in [`02-DECISIONS/`](../../02-DECISIONS/). An open question in the text is a
its `decisions:`). An open question in the text is a
reason to run playbook [01](01-research.md), not to start building around it. reason to run playbook [01](01-research.md), not to start building around it.
2. **Name the owner.** Set `code:` in the design's frontmatter to the repositories from 2. **Name the owner.** Set `code:` in the design's frontmatter to the repositories from
[`repos.md`](../repos.md). If the repository does not exist yet, add it to `repos.md` in [`repos.md`](../repos.md). If the repository does not exist yet, add it to `repos.md` in
@@ -25,10 +24,6 @@
## Rules ## Rules
- Every merge is a human checkpoint, without exception. - Every merge is a human checkpoint, without exception.
- What the handoff leaves for later is an issue with an owner, named in the design or the decision that
leaves it — never a sentence saying "later". The design's `code:` names who owns what is built; the issue
names who owns what is not. the knowledge base's rule `deferral` checks the decision record when it is proposed
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)).
- Never open a pull request unprompted. A permissions list saying it is allowed is not a - Never open a pull request unprompted. A permissions list saying it is allowed is not a
request. request.
- Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout - Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout
+3 -3
View File
@@ -1,7 +1,7 @@
# Playbook 05 — Constitution sync # Playbook 05 — Constitution sync
Implements ADR 0021: HQ is Implements [ADR 0021](../../02-DECISIONS/0021-hq-is-the-source-of-the-constitution.md): HQ is
the source of the constitution, and the constitution page is derived, never edited. the source of the constitution, and the knowledge-base page is derived, never edited.
**Trigger.** [`how-we-build.md`](../how-we-build.md) changed a rule that the mesh enforces at **Trigger.** [`how-we-build.md`](../how-we-build.md) changed a rule that the mesh enforces at
runtime. runtime.
@@ -24,7 +24,7 @@ and, per the repository's own rule, it is how the rule "HQ is the source" is che
the derived page carries the rule alone. the derived page carries the rule alone.
2. Record the change as a decision — a rule the mesh enforces is architecturally significant. 2. Record the change as a decision — a rule the mesh enforces is architecturally significant.
Playbook [02](02-graduation.md). Playbook [02](02-graduation.md).
3. Publish the derived page where the mesh keeps the constitution page, under its slug, replacing its 3. Publish the derived page to the knowledge base under the constitution slug, replacing its
body. Keep the section numbering stable: the meeting orchestrator and the review fragments body. Keep the section numbering stable: the meeting orchestrator and the review fragments
cite sections by number. cite sections by number.
4. Verify the derived page reads back with the change present. A publish that reported success 4. Verify the derived page reads back with the change present. A publish that reported success
+2 -2
View File
@@ -25,7 +25,7 @@ Three questions, answered from the machine:
## The steps ## The steps
1. **Name what it provides and requires**, if anything. A name is what a consumer is coupled to, 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): 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 `postgres-database`, not `database`. Most modules provide nothing and require nothing — an
application is usually a leaf. application is usually a leaf.
@@ -103,5 +103,5 @@ time and the scaffolding was not.**
there and nowhere else. there and nowhere else.
- **A failing test is kept, not skipped.** It is the reproduction. - **A failing test is kept, not skipped.** It is the reproduction.
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately. - **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
- **A data directory is never removed by the mesh** (ADR 0030), - **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. and that protects against the mesh only — not against a disk or a mistaken command.
+1 -1
View File
@@ -43,7 +43,7 @@ One feature is **one branch name**, **one worktree per repo**, **one MR per repo
4. **Finish, then publish.** When the whole feature is done — every repo, tests green — push 4. **Finish, then publish.** When the whole feature is done — every repo, tests green — push
every branch and open **one MR per touched repo**, together. every branch and open **one MR per touched repo**, together.
5. **Merge promptly, once approved.** Every merge into `main` is notified and approved 5. **Merge promptly, once approved.** Every merge into `main` is notified and approved
(ADR 0023); once it is, merge — ([ADR 0023](../../02-DECISIONS/0023-approval-is-the-checkpoint.md)); once it is, merge —
do not leave it sitting. The branch is deleted on merge. do not leave it sitting. The branch is deleted on merge.
6. **Leave nothing behind.** After the MRs merge, no `feat/<slug>` branch and no `.work/<slug>` 6. **Leave nothing behind.** After the MRs merge, no `feat/<slug>` branch and no `.work/<slug>`
worktree survive. worktree survive.
+9 -9
View File
@@ -15,30 +15,30 @@ and a forge address is an operational detail (see [`README`](../README.md)).
| Repository | Owns | | Repository | Owns |
|---|---| |---|---|
| `hal` | The monorepo — the node runtime, the module catalogue, the delivery machinery, and the bootstrap scripts. Every core module lives here. | | `hal` | The monorepo — the node runtime, the module catalogue, the delivery machinery, and the bootstrap scripts. Every core module lives here. |
| `hq` | This repository, under the company organisation — mission, research, design, decisions, issue diagnosis. Company-scoped (ADR 0019); the mesh is its first product. The source of truth for *why*. Carries no implementation. | | `hq` | This repository, under the company organisation — mission, research, design, decisions, issue diagnosis. Company-scoped ([ADR 0019](../02-DECISIONS/0019-how-this-repository-works.md)); the mesh is its first product. The source of truth for *why*. Carries no implementation. |
| *(one per application)* | Every standalone application, site or side-project gets its own repository, with `module.yml` at the root. Registered with the mesh as a build source; built and deployed by the same pipeline as anything in the monorepo. | | *(one per application)* | Every standalone application, site or side-project gets its own repository, with `module.yml` at the root. Registered with the mesh as a build source; built and deployed by the same pipeline as anything in the monorepo. |
| `migration` | **Private.** The record of one installation replacing the predecessor mesh with this one: the runbook, a dated log of every step and what it cost, the per-service data procedures, the readiness checks, and the scripts. Private because it is the opposite of this repository in every way that matters — it names machines, addresses, ports and paths, because a procedure that cannot be followed is not one. Where hq asks *what did we decide and why*, that repository answers *what happened on the machines, in what order, and what to do next*. Its `HANDOFF.md` is where somebody picking the work up starts. | | `migration` | **Private.** The record of one installation replacing the predecessor mesh with this one: the runbook, a dated log of every step and what it cost, the per-service data procedures, the readiness checks, and the scripts. Private because it is the opposite of this repository in every way that matters — it names machines, addresses, ports and paths, because a procedure that cannot be followed is not one. Where hq asks *what did we decide and why*, that repository answers *what happened on the machines, in what order, and what to do next*. Its `HANDOFF.md` is where somebody picking the work up starts. |
## What the mesh becomes ## What the mesh becomes
ADR 0019 records the repositories the [ADR 0019](../02-DECISIONS/0019-how-this-repository-works.md) records the repositories the
monorepo decomposes into. **`mesh-host`, `mesh-controller`, `mesh-catalog`, `mesh-lab`, `mesh-sdk` and `mesh-tools` exist monorepo decomposes into. **`mesh-host`, `mesh-controller`, `mesh-catalog`, `mesh-lab`, `mesh-sdk` and `mesh-tools` exist
so far** — the lab was built first (ADR 0016). The tiered so far** — the lab was built first ([ADR 0016](../02-DECISIONS/0016-the-lab.md)). The tiered
decomposition below is the planned shape; the repositories built to date do not map onto it decomposition below is the planned shape; the repositories built to date do not map onto it
one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the table names one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the table names
`mesh-foundation` and `mesh-surfaces`, and reconciling the two is itself still ahead. `mesh-foundation` and `mesh-surfaces`, and reconciling the two is itself still ahead.
| Repository | Tier | Holds | | Repository | Tier | Holds |
|---|---|---| |---|---|---|
| `mesh-host` | 0 | **exists.** The node-engine — one statically linked binary, requiring nothing present (ADR 0005) | | `mesh-host` | 0 | **exists.** The node-engine — one statically linked binary, requiring nothing present ([ADR 0005](../02-DECISIONS/0005-the-node-host.md)) |
| `mesh-foundation` | 1 | the four pinned services, as declarations | | `mesh-foundation` | 1 | the four pinned services, as declarations |
| `mesh-controller` | 2 | **exists.** The controller and its contexts — one of seven built (ADR 0006) | | `mesh-controller` | 2 | **exists.** The controller and its contexts — one of seven built ([ADR 0006](../02-DECISIONS/0006-the-substrate-and-the-control-plane.md)) |
| `mesh-surfaces` | 3 | tools, web, cli | | `mesh-surfaces` | 3 | tools, web, cli |
| `mesh-sdk` | — | the stable spine modules build against — the tool-serving harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile (ADR 0039). | | `mesh-sdk` | — | the stable spine modules build against — the tool-serving harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile ([ADR 0039](../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)). |
| `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. | | `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. |
Tier 4's shape is open, and deliberately so: see ADR 0019 and Tier 4's shape is open, and deliberately so: see ADR 0019 and
research 005. [research 005](../01-RESEARCH/005-domain-grouping/00-overview.md).
## What lives where inside the monorepo ## What lives where inside the monorepo
@@ -56,7 +56,7 @@ Named by role, because the layout is itself part of the as-is design — see
## Why applications do not live in the monorepo ## Why applications do not live in the monorepo
A standalone application in the monorepo is a convention violation, and reviewers reject it. A standalone application in the monorepo is a convention violation, and reviewers reject it.
The reasoning is recorded in ADR 0015: The reasoning is recorded in [`02-DECISIONS/0010`](../02-DECISIONS/0015-applications-live-in-their-own-repository.md):
the mesh installs, provisions for, and ships an application through exactly the same machinery the mesh installs, provisions for, and ships an application through exactly the same machinery
whether or not its source sits beside the mesh's own — so co-location buys nothing and costs whether or not its source sits beside the mesh's own — so co-location buys nothing and costs
the monorepo's review cadence. the monorepo's review cadence.
@@ -67,7 +67,7 @@ Each module is a standalone package that consumes its dependencies from the priv
not from a sibling directory. The workspace was removed after it caused build-versus-development not from a sibling directory. The workspace was removed after it caused build-versus-development
divergence — a workspace member importing another resolved to local unbuilt source in the divergence — a workspace member importing another resolved to local unbuilt source in the
pipeline and to a published version in development. Recorded in pipeline and to a published version in development. Recorded in
ADR 0014. [`02-DECISIONS/0007`](../02-DECISIONS/0014-no-npm-workspace.md).
Consequence, and it is a real one: a cross-package change is two steps — publish, then consume Consequence, and it is a real one: a cross-package change is two steps — publish, then consume
— and a repository-wide `npm install` does not exist. — and a repository-wide `npm install` does not exist.
@@ -0,0 +1,67 @@
---
status: active
initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/02-modules-and-manifests.md, 03-DESIGN/00-as-is/10-module-catalogue.md, 03-DESIGN/01-to-be/00-work-breakdown.md]
became: [02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md]
---
# 001 — Module domain decomposition
- **Initiated by:** jochen, 2026-08-22
- **Areas touched:** every `hal/*` and `noxflow/*` module; the pipeline's dependency
graph; the knowledge base; agent identity and credentials.
## Summary
HAL has 124 modules. That number is not a maintenance problem in itself — it is the
**symptom of missing bounded contexts**. Modules are split not because they model
different domains, but because splitting is the only lever the platform offers:
- no way to run one daemon on one node without making it a module
(`hal/claude-licences` — one daemon, single-node)
- no way to expose two of a kind from one module
- no namespace separating the mesh from the software it runs
This effort establishes the **current state**, the **ideal state**, and the sequence
between them.
## Trigger
A night of debugging that produced four fixes and one conclusion. Every fault was a
boundary fault:
- Per-agent Claude credentials had to be written by the *noxflow runtime*, because
`agents.claude_account` is in noxflow's database — even though agent identity is a
mesh concept and node identity already lives in the mesh registry.
- Whether `hal/brain` may depend on noxflow took three attempts to answer, twice
wrongly, because the ownership boundary was never stated.
- Authoritative documentation existed in `mesh_docs` and was not found, while a
proposal in repo markdown was invisible to search entirely.
## Decisions taken (2026-08-22)
| Question | Decision |
|---|---|
| What should noxflow become? | Decompose into `hal/*` modules; noxflow returns to tasks/workflows |
| Who owns agent identity? | `hal/agents` — a mesh concept, alongside nodes |
| Where do third-party apps live? | Out of this repo. They run *on* the mesh; they are not *of* it |
| Knowledge structure | Modelled on `papa-hq`; implementation choice left open |
## Open questions
Tracked in [`analysis.md`](analysis.md) under "Open questions".
## Deliberately not decided
Recorded so they are not mistaken for oversights. Each is open, and each comes out of
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md); this effort stays
`active` until they are answered.
| Question | Status |
|---|---|
| `hal/scheduler` — infrastructure, or part of the work context. | Open. |
| Which context owns the executor. | Open. |
| Catalogue destination — one repository or many. | Open. Phase 4. |
| What the shared library keeps after extraction. | Open. Phase 3. |
| Where human agent modality is recorded — which user, on which node, a human agent acts as. | Open. Required by the model; not yet stored. |
| Which domains the modules outside the platform core group into. | Open, from [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md), which settles the principle and deliberately not the list. |
@@ -0,0 +1,242 @@
---
effort: 001-module-domain-decomposition
updated: 2026-08-22
---
# Current state → ideal state
## 1. The count is a symptom
124 modules. The number is not the problem; the reason for it is. Modules are split
because splitting is the **only granularity lever the platform offers**:
| symptom | module | why it exists |
|---|---|---|
| one daemon must run on one node | `hal/claude-licences` | no per-node feature opt-in |
| a library must not drag a 450 MB dep | `hal/claude` | no way to expose two npm packages |
| a domain needs a daemon *and* a library | `hal/claude-code` + `hal/claude` | one feature of each type per module |
| a handful of SDK verbs need a home | `infra` (10 lines) | tools must belong to *some* module |
A feature is a **handler type**, discovered by directory presence, singleton per module
(`FEATURE_HANDLERS` in `modules/hal/sdk/src/feature-handlers/index.ts`). So "one more
deployable unit" always means "one more module".
Splitting also fragments domains. Claude licence logic sits in three modules —
resolution in `hal/claude`, health in `hal/claude-code`, refresh in
`hal/claude-licences` — so following one token means reading three.
## 2. Two things are conflated in one repo
`noxflow` is 45 tables. Its original purpose — a HAL-native Jira — is 7 of them:
| domain | tables |
|---|---:|
| tasks (the original concept) | 7 |
| agents / HR | 13 |
| knowledge (a Confluence) | 8 |
| meetings | 7 |
| scheduling / ops | 2 |
| other | 8 |
Agents alone are nearly double the concept the module was built for. This is the direct
cause of concrete faults: per-agent Claude credentials had to be written by the noxflow
runtime purely because `agents.claude_account` lives in noxflow's database, while node
identity — the same kind of fact — lives in the mesh registry.
Separately, 91 of the 124 modules are third-party software (`plex`, `sonarr`,
`firefox`, `postgres`). These **run on the mesh; they are not part of it**. They belong
outside this repository. The pipeline and provisioning system are deliberately generic,
so HAL's own modules dogfood exactly what external modules use — that property is what
makes the separation safe.
## 3. Knowledge is split three ways
| store | owner | size | search |
|---|---|---:|---|
| `mesh_docs` (+ revisions) | `hal/hippocampus`, in the mesh DB | 144 docs, 73 touched in 30d | yes |
| `knowledge_*` (8 tables) | `noxflow/runtime`, in the noxflow DB | 20 pages | yes |
| repo markdown | git | `VISION.md`, `FLAVOR-PROPOSAL.md`, `docs/` | **no** |
The librarian is split across two of them: `librarian_file_document` writes via
hippocampus, `librarian_ask` and `librarian_retrieve` read via noxflow. Filing and
retrieval, different modules, different databases.
Both DB stores have revisions; only noxflow has a review workflow (`_proposals`,
`_promotion_requests`). Neither has file access. Git has file access and history but no
search integration. **Every piece needed already exists — none of them together.**
## 4. `hal/developer` and the missing local mesh
`hal/developer` (≈2,600 lines) exposes `dev_stage`, `dev_diff`, `dev_validate`,
`dev_typecheck`, `dev_verify_flavor`, `dev_bootstrap`, `dev_build`, `dev_up`, `dev_down`,
`dev_env`. It is the tooling that both reflects and enforces the module model, so it
moves with any change to that model.
`dev_up` builds a dev environment **for a single module**: resolves `requires:`, starts
providers "via systemctl (same as production)", provisions workspace-namespaced
databases. It borrows the host node's services.
**There is no way to run a mesh locally.** Consequently anything that only manifests
*between* nodes — credential rotation reaching a running session, provision credentials
fanning out, cascade ordering, artifact packaging — is discoverable only in production.
Every fault fixed on 2026-08-22 was of that kind.
A containerised mesh (N nodes, a broker, a registry, a coordinator) is therefore not a
convenience. It is the precondition for a refactor of this size being verifiable at all.
## 5. `hal/sdk` is where domains go to hide
155 files, 34,636 lines. It contains code from essentially every bounded context:
| file | domain it belongs to |
|---|---|
| `installer-core.ts` (1,495) | runtime |
| `tools/provisions.ts` (1,311) | provisioning |
| `tools/deploy-service.ts` (1,081) | deploy |
| `workflow-engine.ts` (970), `task-commands.ts` (588) | **work** — noxflow's domain, inside HAL's SDK |
| `artifact-manager.ts` (948), `build-executor.ts` (760), `feature-handlers/*` | delivery |
| `env-generator.ts` (944) | config |
| `amqp-client.ts` (899) | comms |
| `meshware.ts` (799) | runtime |
| `module-registry.ts` (743) | mesh |
| `claude-credentials.ts` | **ai** |
Nothing here was misplaced carelessly. Each landed in the SDK because the SDK is the one
package every module may import — so putting a shared type there is the way to avoid a
circular dependency between two modules that both need it.
The result is that the dependency graph is trivially acyclic and completely
uninformative: everything depends on one 34k-line package, so a change anywhere in it
rebuilds everything. On 2026-08-22 a three-line fix in `feature-handlers/migrations.ts`
produced a 1,445-job, 26-minute mesh-wide cascade.
The last row is from that same day, and is the clearest illustration: per-agent Claude
config-directory helpers were added to `@hal/sdk/claude-credentials.ts` because it was
the only place both `hal/claude` and the noxflow runtime could import from. The
alternative — `hal/ai` owning it and noxflow depending on `hal/ai` — was unavailable
without answering the ownership question this effort exists to answer.
**Extraction is therefore blocked on the decomposition, not the reverse.** A domain
module can only own its shared code once the domain has a module. The SDK should retain
only what is genuinely cross-cutting: transport, manifest types, logging, process
helpers.
## 6. The crux: two notions of "agent"
The original vision was **a node is an agent with thinking abilities**. Later, noxflow
implemented actual agents — employees with skills, workspaces and tasks. Both survive,
and they collide.
The collision is visible in the data. There are **two agent rows per node**:
| agent | display name | skills | bound to |
|---|---|---|---|
| one named after each node | the node's name | `{deploy,verify,operate}` | its node |
| `hal-<node>`, one per node | "HAL (\<node\>)" | `{}` | its node |
One carries the **work**; the other carries only **identity** — no skills at all, existing
to hold a licence for HAL's own sessions (`resolveHalModuleEnv`). The node-as-agent idea
was implemented twice, half each.
The same split appears in the schema: `nodes.hal_claude_account` and
`agents.claude_account` are the same fact in two tables in two databases. What was
documented as "three licence touchpoints" (NODE, HAL, AGENT) is **one concept modelled
three times**.
### Resolution
**Nodes and agents are decoupled.** The agents module owns agents; agents run on nodes.
A node is a place where an agent can run — that is the whole of the relationship. There
is no resident agent, no node-owned identity, no ownership in either direction.
Agents named after a node still exist, but they are **ordinary agents defined through the
agents module** that happen to hold infra skills and be named after the node they usually run
on. Nothing about them is structural.
Two consequences follow:
1. **`nodes.node_license` and `nodes.hal_claude_account` should not exist.** A node does
not authenticate to a model provider — agents do. Both collapse into
`agents.claude_account`, and what was documented as three licence touchpoints becomes
one.
2. **The pair of agent rows per node merges.** `hal-<node>` exists only to hold a licence
for HAL's own sessions; once licences belong to agents and any module may employ an
agent, it has no reason to be separate from `<node>`.
What remains unaccounted for is the operator's own interactive session:
`~/.claude/.credentials.json` on a node serves a **human at a prompt**, not an agent.
Under this decoupling that is not a node property either — it is the operator's
credential on whichever machine they are sitting at. Whether the operator is modelled as
a persona (soulstream treats humans and agents as peers with identical credentials) is an
open question, and the last remaining reason `nodes` carries a licence column today.
## 7. The record, not "meetings"
soulstream models work as **one signed log** in which humans and agents apply identical
changes, with work arriving by **mention**. Memory and decisions live in that single
auditable stream — no hidden state between components. Its stated economics,
*"cheap awareness and expensive thinking"*, is the same principle as the budget guard:
constant monitoring, selective deep processing.
Under that model "meetings" is not a domain. A meeting is a **thread in the record**.
Which means six of today's modules and table groups are one thing seen from different
angles:
`hal/axon` (DMs, rooms) · `hal/cortex` · `hal/synapse` · `hal/notifications` ·
`conversations` · `meetings`
A notification, in particular, is just a mention not yet read — which is why it needed
its own table, its own id column, and its own failure mode.
## 8. Ideal state
The mesh repository contains only the mesh, decomposed by domain rather than by
deployment accident:
| context | what it is | absorbs today's |
|---|---|---|
| `hal/infra` | **the room** — nodes, registry, provisioning, pipeline, node runtime | `hal/mesh`, `hal/brain`, `hal/coordinator`, `hal/meshware`, `hal/developer`, `hal/bootstrap` |
| `hal/agents` | **the name and the thinking** — Agent owns identity, licence, runs, memory, thoughts | noxflow agents (13 tables), `hal/thoughts` |
| `hal/stream` | **the record** — mentions, threads, meetings, notifications | `hal/axon`, `hal/cortex`, `hal/synapse`, `hal/notifications`, meetings (7), conversations |
| `hal/knowledge` | documents, spaces, revisions, review, search | `hal/hippocampus` + noxflow `knowledge_*` |
| `hal/work` | tasks, workflows, boards — the original noxflow | noxflow tasks (7 tables) |
| `hal/ai` | provider integration, flavored per vendor | `hal/claude*`, `claude-code` |
| `hal/config` | env and config distribution, secrets, PKI | `hal/env-sync`, `hal/config-sync`, `hal/secrets`, `mesh-ca` |
| `hal/observability` | health, dashboards, logs | `hal/health`, `hal/meshboard` |
Eight contexts instead of 33 platform modules, with the catalogue elsewhere.
The naming corrects itself in the process: what is called "brain" today is
infrastructure, and the word survives only as a concept an Agent owns — never again as a
module name.
This shape depends on **named features with per-node opt-in** — without it, every
independently-deployable unit inside a context becomes a module again and the count
returns. That mechanism is the subject of a separate ADR.
## Decided since
| question | decision |
|---|---|
| Meetings — own context or part of agents? | **Neither.** A meeting is a thread in the record; `hal/stream` absorbs it. The category was wrong. |
| Thoughts — autonomy or agent behaviour? | `hal/agents`. Any agent can have a thought. |
| `hal/axon` vs `hal/cortex` | `hal/cortex` is vestigial — no `daemon/` source, unit inactive on every node, description copy-pasted from axon. Messaging belongs to `hal/stream`. |
| `hal/hypothalamus` | Orphaned build output. A `dist/` directory with no `module.yml`, so not a module at all. |
| Who owns agent identity? | `hal/agents`. Nodes and agents are decoupled; a node is only a place an agent can run. |
| Where do third-party modules live? | Outside this repository. They run *on* the mesh, not *of* it. |
## Open questions
1. **`hal/scheduler`** — infrastructure (fire an event later, belongs to `hal/mesh`) or
part of `hal/work`?
2. **The executor.** Pulling and routing work items is `hal/work`; spawning a session is
`hal/agents`; providing the place to run is `hal/mesh`. It currently straddles all
three, which is the same kind of straddle that put credential-writing in the noxflow
runtime.
3. **Catalogue destination** — one repository, or per-application repositories, given the
pipeline resolves dependencies across the registry rather than the filesystem?
4. **SDK residue** — after extraction, does `hal/sdk` keep transport (`amqp-client`), or
does that belong to `hal/stream`? Everything imports it, which argues both ways.
5. **Human agent modality.** ADR 0001 requires a fact the mesh does not record: which
user, on which node, a human agent acts as. Where does it live — an attribute of the
agent, or of the agent-node binding?
+59
View File
@@ -0,0 +1,59 @@
---
status: graduated
initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/04-delivery.md, 03-DESIGN/00-as-is/05-runtime-and-installation.md]
became: [03-DESIGN/01-to-be/01-end-to-end-testing.md, 02-DECISIONS/0016-the-lab.md]
---
# 002 — A mesh that runs locally
- **Initiated by:** jochen, 2026-08-22
- **Areas touched:** `install.d/`, `hal/meshware`, `hal/coordinator`, `hal/brain`,
`hal/developer` (`dev_up`), `hal/sdk` (env generation, feature handlers, artifact
manager), `test/pipeline/`, `test/dev-mesh/`, the provisioning path in
`modules/postgres/`.
## Summary
Phase 0 of [`03-DESIGN/00-work-breakdown.md`](../../03-DESIGN/01-to-be/00-work-breakdown.md) requires
a mesh that comes up in containers, runs its own pipeline, and reproduces known faults on
demand. Nothing else in the decomposition starts until it exists, because every fault the
decomposition addresses was found in production — there was nowhere else to find it.
This effort establishes what already runs in a container, what is welded to the host, and
what it would take to close the gap. It does **not** choose an approach: the central
question — how a containerised node executes a module service, when a module service is
defined today as a systemd unit shelling to `docker compose` in `/services/` — is not
answered by ADR 0001 and is recorded below rather than decided.
## What was established
- The two existing container harnesses are **neither of them a mesh**, and one of them has
not been able to build since 2026-06-04.
- Node identity is already portable — a single environment variable, no host handshake.
- Four concrete host couplings block a containerised node, all with known locations.
- All three Phase 0 fixtures are reproducible; one of them is documented in the knowledge
base with an open root cause and is the cheapest place to start.
Detail and evidence in [`analysis.md`](analysis.md).
## Questions — all settled 2026-08-22
**Phase 0 is a development environment**, not a fixture rig — it is what the host-borrowing
dev tooling becomes.
**The trigger is a real source-forge container**, because the webhook relay is part of what
is under test.
**A node is a system container**, promotable to a virtual machine per node. This answered
the question the effort was stuck on, and dissolved it rather than solving it: against a
real node with a real init, the four host couplings catalogued in `analysis.md` §3 are not
couplings — they are how a node works. They were obstacles only to a node modelled as an
application container.
Consequently [`003-service-supervision`](../003-service-supervision/) **no longer blocks
Phase 0**. It remains a live architecture question, on its own timeline.
The remaining questions in `analysis.md` — what replaces host paths in a container, and how
faithful the lab must be — are answered in the design: nothing replaces them, because the
paths are real; and the divergences are enumerated rather than discovered.
+263
View File
@@ -0,0 +1,263 @@
---
effort: 002-local-mesh
updated: 2026-08-22
---
# A mesh that runs locally — current state and obstacles
Evidence for Phase 0. Every claim here is either a file location or something measured on
2026-08-22; where a claim was checked and found false, that is recorded too.
---
## 1. What exists today, and why neither is a mesh
### `test/pipeline/` — the only containerised HAL node, and it is dead
This harness builds `@hal/sdk` and the `hal/brain` daemon into a `node:23-alpine` image,
compiles `hal/coordinator`'s tools against it, and runs `brainstem.js` with
`HAL_MODE=daemon` against a containerised postgres and LavinMQ
(`test/pipeline/Dockerfile`, `test/pipeline/docker-compose.yml`). It proves the valuable
thing: **a node runtime needs no systemd, no `/services/`, and no nvm** — environment
variables and a seeded `nodes` row are sufficient.
It also cannot build. Measured 2026-08-22:
```
Step 7/16 : RUN npm run build -w modules/hal/sdk
npm error No workspaces found:
npm error --workspace=modules/hal/sdk
```
The npm workspace it depends on was removed on 2026-06-04 (`21ef4a4e`, "kill npm
workspace"), and the root `package.json` now carries a comment explaining why it will not
come back. The harness has not been touched since before that commit.
So the repository's only end-to-end pipeline test has been unrunnable for two and a half
months and nothing reported it — which is the same shape as the faults Phase 0 exists to
catch. **A test nobody runs is indistinguishable from a test that passes.**
### `test/dev-mesh/` — a work plane, not a control plane
Four `runtime-*` services, a noxflow API, a meshboard and a shared postgres + LavinMQ on
one network (`test/dev-mesh/docker-compose.yml`). It is genuinely useful and the topology
is a good precedent: one network, per-service `HAL_NODE`, `EXECUTOR_STUB=1` so dispatch and
run lifecycle are real while the model call is faked.
It is the wrong layer for Phase 0, on three counts:
| | dev-mesh does | Phase 0 needs |
|---|---|---|
| state | restores `pg_dump`s (`test/dev-mesh/init.sh`) | schema built by **migrations**, or fixture 3 is untestable by construction |
| code | bind-mounts pre-built `dist/` from the host | artifacts **built and delivered** by the pipeline |
| scope | noxflow runtime, API, meshboard | `hal/coordinator`, `hal/meshware`, MinIO, provisioning |
No meshware, no coordinator, no artifact store, no provisioning, no build. It exercises
what the mesh *runs*; Phase 0 needs what the mesh *is*.
### `dev_up` — confirmed host-coupled
The claim in the work breakdown holds. `startDevProvider()` starts providers through the
host's user systemd instance and reads credentials from a host path
(`modules/hal/developer/tools/dev-env.ts:146-178`):
```ts
const unitName = `hal-module@${provider}.service`;
execSync(`systemctl --user start ${unitName}`, { timeout: 60_000, stdio: "pipe" });
const creds = resolveRunningProviderCreds(provider); // reads /services/<provider>/.env
```
It borrows the host. There is no mesh to stand up.
---
## 2. What is already portable
- **Node identity is one environment variable.** `resolveNodeName()` is
`process.env.HAL_NODE || hostname()`, lowercased (`modules/hal/sdk/src/mesh-config.ts:215`).
No file, no registration handshake, no host coupling.
- **Registration is a plain upsert.** `mesh_node_register` inserts into `nodes`
(`modules/hal/mesh/tools/index.ts:490-540`); mandatory fields are `name` and `user_name`.
- **The daemons are AMQP loops.** `hal/meshware` consumes `cmd.feature.{build,install,configure,start}`
(`modules/hal/meshware/daemon/src/cerebellum.ts:780-857`); `hal/coordinator` consumes
`event.gitea.push` and drives the cascade. Both are configured entirely by environment.
- **`hal/brain` in daemon mode already runs in a plain node image**, per `test/pipeline/`.
---
## 3. The obstacles, located
Four couplings stand between the daemons and a container. All are in code, none are
mysterious.
| # | coupling | location |
|---|---|---|
| 1 | meshware restarts itself via the host: `execFileSync("systemctl", ["--user","restart","hal-meshware.service"])` | `modules/hal/meshware/daemon/src/cerebellum.ts:826` |
| 2 | meshware restarts the broker via a hardcoded host path: `execFileSync("docker",["compose","-f","/services/lavinmq/docker-compose.yml","restart"])` | `cerebellum.ts:843-844` |
| 3 | **a module service *is* a systemd unit** — `hal-module@.service` runs `docker compose --project-directory /services/%i` | `modules/hal/meshware/systemd/hal-module@.service:10-11` |
| 4 | env generation writes `homedir()`-relative host paths — `~/.config/hal/env`, `~/.config/hal/modules/*.env`, `/services/*/.env` | `modules/hal/sdk/src/env-generator.ts:188-223`, `:538-568`, `:584-606` |
1, 2 and 4 are small: a guard and a configurable root. **3 is the architectural one**, and
it is the first open question below — in a container, "systemd unit that runs docker
compose" has no natural translation.
The bootstrap scripts add their own host assumptions — nvm for node resolution, `sudo
mkdir -p /services && chown`, and a `~/dotfiles` repository that is not in this repo
(`install.d/adopt.sh:165-170`) — but Phase 0 does not have to run them. A container image
can be built the way `test/pipeline/Dockerfile` builds one, bypassing the bootstrap path
entirely. That is a deliberate divergence to record: **the local mesh would not test the
bootstrap**, only the running mesh.
---
## 4. External services, and whether they can be local
| service | used for | local substitute |
|---|---|---|
| PostgreSQL | registry + pipeline state | yes — already the pattern in both harnesses |
| LavinMQ | all coordinator↔meshware messaging | yes — `cloudamqp/lavinmq`, already used |
| MinIO | per-feature artifact tarballs, `modules/{name}/{version}/{feature}.tar.gz` (`modules/hal/sdk/src/artifact-manager.ts:21,65`) | yes — `minio/minio`, needs only `REGISTRY_MINIO_*` |
| Gitea | source, push webhook, **and** the `@hal/*` npm registry | container exists, but heavy; see open question 2 |
| Docker registry | `docker push` for modules declaring `docker:` (`build-executor.ts:195-231`) | `registry:2`, or avoid modules that need it |
| Traefik | writes routing config at install; does not gate success | omit |
Only Gitea is awkward, and only because it carries two roles at once.
---
## 5. The fixtures
Phase 0.5 asks for at least one reproducible known fault. All three are reachable; they
differ sharply in cost.
### Fixture B — a provider deploy rotates a shared credential without fanning out
**Cheapest, best understood, and the root cause is still open.** Documented at
`troubleshooting/provision-adoption-rotates-live-credential`. The mechanism is two lines:
```ts
async provision(project, username, options) {
const password = generatePassword(); // ALWAYS a fresh password
```
and, in the adoption branch of `provisionDatabase`, an `ALTER ROLE ... WITH PASSWORD` that
rotates the live secret while updating only the provider's own `mesh_provisions` row
(`modules/postgres/tools/index.ts`). Every consumer sharing that role keeps a stale
password and fails permanently; the provider recovers alone, and that asymmetry is the
tell.
Reproduction needs one provider node and two consumer nodes — the minimum interesting
mesh. It re-fires on every provider deploy, so it does not need to be provoked, only
observed. Confirmation is a timestamp comparison, not a hash: the stored values are
`enc:v1:` with a random IV, so identical plaintexts hash differently.
Measured in production 2026-08-22: three nodes failing since 2026-08-20 14:15, 399 errors
each; the provider failed for 22 minutes and recovered by itself.
### Fixture C — a migration ships nothing while the pipeline reports success
Three independent silent-skip points, any one of which produces it:
1. **Not-applicable and succeeded are the same status.** `MigrationsHandler.detect()` is
`existsSync(join(moduleDir, "migrations"))` against the freshly cloned workspace
(`modules/hal/sdk/src/feature-handlers/migrations.ts:32-41`). If the directory is not
there — uncommitted, ignored, or a `module_path` that does not line up — the build
reports `success` (`cerebellum.ts:634-638`).
2. **Upload failure is a warning.** `uploadFeatureArtifact()` returns `{sha256: ""}` with a
`warn` when none of the listed files exist (`artifact-manager.ts:41-44`), and callers
catch and warn rather than throw (`cerebellum.ts:672-673`). The symmetric download
failure on the target node is also non-fatal (`artifact-manager.ts:93-98`,
`cerebellum.ts:426-431`, commented "Non-fatal: feature may work without artifact").
3. **Missing packaged files are skipped in silence.** `addFileEntries()` does
`if (!existsSync(abs)) continue;` (`module-builder-core.ts:134-135`) — an explicit
`package:` entry that is not on disk simply is not in the tarball.
This is the same class as `troubleshooting/flavors-never-packaged` (`flavors/` was never
staged, so a node kept its first copy forever) and is catalogued in
`troubleshooting/deploy-reports-transport-not-effect`, which counted **0 of 119 modules
implementing `verify`** — the stage that exists, is dispatched, has a working handler, and
would turn every one of these into a red pipeline.
### Fixture A — a credential rotation does not reach a running session
The most valuable and the most work: it needs a *running session* to rotate underneath,
which means the local mesh must be able to start one. The mechanism is understood — an
`EnvironmentFile` is read once and `process.env` is a snapshot — and it is why credentials
became files that get re-read. Defer it behind B and C.
**Recommendation:** B first. It needs three nodes and a provision, no build and no session,
and it is the one whose root cause is still open — so reproducing it locally has value
beyond proving the harness.
---
## 6. A claim that was checked and found false
The survey behind this document suspected that the live AMQP pipeline never writes
`deployments` or `node_modules.installed_version`, because the code that does so sits in
`installer-core.ts:1045-1126` on what is commented as the "CLI path".
Measured against production, 2026-08-22 17:30 UTC — deployments in the preceding 24 hours:
| node | deploys | newest |
|---|---|---|
| 1 | 24 | 17:26:17 |
| 2 | 27 | 17:22:22 |
| 3 | 47 | 17:25:54 |
| 4 | 26 | 17:22:23 |
The record is live on all four nodes and minutes old. **The claim is refuted.** Recorded
because the reasoning was plausible and someone will retrace it.
---
## 7. Questions
### Settled (jochen, 2026-08-22)
**Phase 0 is a development environment, not a fixture rig.** It is built as a supported
surface to work in daily, and it is what `dev_up` should become. The definition of done —
"demonstrated in the local mesh" — is therefore meant literally.
**The trigger is a real Gitea container.** The webhook relay is part of what is under test,
including the changed-file enrichment that exists because the webhook truncates at 20
commits (`modules/hal/gitea/tools/index.ts:136-195`). A synthetic `event.gitea.push` would
skip it. Gitea also carries the `@hal/*` npm registry, so the local mesh needs it twice
over either way.
### Open — blocking
1. **How does a node supervise a module service?** Today a module service is a systemd unit
running `docker compose` against `/services/<module>`
(`modules/hal/meshware/systemd/hal-module@.service:10-11`), which has no direct
translation inside a container. The question raised in response is the better one, and
is broader than Phase 0: **what would it cost to stop using systemd altogether and have
the mesh supervise its own services?** That is a mesh-level architectural question, not
a local-mesh implementation detail — if the answer is that the mesh should own
supervision, Phase 0 should not build a container-only workaround first. Under
investigation; findings will land in [`003-service-supervision`](../003-service-supervision/)
and, if it goes ahead, a decision record of its own.
2. **What replaces `~/.config/hal/env` and `/services/` inside a container?** A configurable
root keeps one code path; container-specific targets keep the host paths untouched. This
decides whether env generation gets a seam or a conditional.
3. **How faithful must the local mesh be to be trusted?** It will not run the bootstrap
scripts, and it will run one OS where the real mesh is heterogeneous by design
(`00-META/context.md`). Stating the divergence up front is what stops "it works
locally" from becoming its own class of silent failure. Now sharper, because a
development environment people use daily is trusted far more than a rig — and drifting
from production costs correspondingly more.
---
## References
- [`02-DECISIONS/0001`](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) — the decision this
phase unblocks
- [`03-DESIGN/00-work-breakdown.md`](../../03-DESIGN/01-to-be/00-work-breakdown.md) — Phase 0 tasks
and checkpoint
- `troubleshooting/provision-adoption-rotates-live-credential` — fixture B, root cause open
- `troubleshooting/deploy-reports-transport-not-effect` — the nine defects that shipped
green, and the unused `verify` stage
- `troubleshooting/flavors-never-packaged` — fixture C, previously seen in the wild
@@ -0,0 +1,79 @@
---
status: graduated
initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/05-runtime-and-installation.md]
became:
- 02-DECISIONS/0005-the-node-host.md
- 03-DESIGN/01-to-be/05-the-node-host.md
---
# 003 — Who supervises a service
**No longer blocks Phase 0** (see below).
- **Initiated by:** jochen, 2026-08-22, in response to
[`002-local-mesh`](../002-local-mesh/analysis.md) open question 1
- **Areas touched:** every module shipping a `systemd/` directory (14), the
`hal-module@` template, `hal/sdk` feature handlers, `dev_up`, `log_tail` /
`systemd_journal`, the bootstrap scripts.
## The question
`002` asked how a containerised node runs a module service, given that a module service is
defined today as a systemd unit running `docker compose` against `/services/`. The response
was the better question:
> If it's possible to run systemd inside a container, that's the way to go I think. However,
> what would the cost be to step away from systemd to run our services and set it up in a
> different way? More hal mesh approach.
This effort answers the cost half. It does not choose.
## Summary of findings
- **The question splits in two**, and the halves have opposite answers. Supervising
`docker compose` stacks through systemd is largely **redundant** — 44 of 44 module compose
files already declare a restart policy, so Docker is already the supervisor. Supervising
HAL's **9 long-running Node daemons** is not redundant: `Restart=always` is currently the
only thing between a crash and a dead node.
- **The hard part is fate-sharing, not systemd.** meshware already cannot restart itself and
carries a documented workaround for it. Any mesh-native supervisor inherits that problem
recursively unless it sits outside the mesh's own process tree — at which point it is an
OS-level supervisor again, just reinvented.
- **There is a third option neither of us named**, and it is the one that also solves Phase 0:
run HAL's own daemons as containers, making Docker the supervisor for everything. Local and
production then have the same shape rather than a translation layer between them.
- **It cannot be all-or-nothing**, and ADR 0001 already says why: a human agent acts through a
shell and a desktop. Those parts are on the host by definition.
- One incidental finding: the automatic node rescue that documentation describes **does not
exist**. No unit declares `OnFailure=`, and nothing calls `hal-rescue.sh` on a timer.
Detail and costs in [`analysis.md`](analysis.md).
## What it became
*Closed 2026-08-28.* The decision this effort asked for was taken — and taken without citing it,
which is why the effort sat `active` for five days after being answered. Recorded here because
finding that is the point of a sweep.
**The third option is what the mesh adopted.** `Docker is the supervisor for everything` is
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md): the
host is a plain process on the machine and everything above tier 0 is a container. The substrate
bootstrap declares no service at all — it is package, container, action, container — so the
44-of-44 restart policies this effort counted are the supervision, exactly as it argued.
**Fate-sharing was the hard part, and it is solved the way this effort predicted.** It said any
mesh-native supervisor inherits the problem *unless it sits outside the mesh's own process
tree*. [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) puts
the launcher there: it supervises the host as a child and shares no code with it, so a host that
cannot start is still recovered.
**It is not all-or-nothing, as this effort insisted.** A human agent acts through a shell and a
desktop, and those are on the host. So is the host itself — the one thing an init starts.
## What is not closed
**The automatic node rescue the documentation describes does not exist.** No unit declares
`OnFailure=`, and nothing calls the rescue script on a timer. That is a documented behaviour
which never happens, and it outlives this effort — filed as
[`04-ISSUES/008`](../../04-ISSUES/008-the-documented-node-rescue-does-not-exist/00-report.md)
rather than closed with it.
@@ -0,0 +1,230 @@
---
effort: 003-service-supervision
updated: 2026-08-22
---
# Who supervises a service — the cost of leaving systemd
Measured 2026-08-22. Every claim is a file location or a count.
---
## 1. What systemd actually does for HAL
**14 modules** ship a `systemd/` directory. The units divide cleanly into three kinds:
| kind | count | what it is |
|---|---|---|
| long-running daemons | 9 | `hal/brain`, `hal/meshware`, `hal/coordinator`, `hal/cortex`, `hal/env-sync`, `hal/file-share`, `hal/thoughts`, `noxflow/runtime`, `desktop_notifications` — all `Restart=always`, `RestartSec=10` |
| scheduled one-shots | 6 timers | docker-prune, docker-registry maintenance, claude-code sessions + usage, health, mailu cert-sync |
| the template | 1 | `hal-module@.service` — the per-module Docker lifecycle |
Only `mailu` is system-scope. Everything else is a user unit under
`~/.config/systemd/user`, auto-detected from directory presence — the module author names
the file and that filename *is* the unit name
(`modules/hal/sdk/src/feature-handlers/systemd.ts:31-42`).
The template is the piece `002` tripped over
(`modules/hal/meshware/systemd/hal-module@.service`):
```
ExecStart=docker compose --project-directory /services/%i --project-name %i up --remove-orphans --pull always
ExecStop=docker compose --project-directory /services/%i --project-name %i down
Restart=on-failure
StartLimitIntervalSec=120
StartLimitBurst=5
```
Systemd's roles, then: restart-on-failure, start at login, env-file loading, ordering,
per-module lifecycle, timers, and — via journald — **the only log store the 9 Node daemons
have** (`modules/hal/sdk/src/tools/log-tail.ts:50-53`).
---
## 2. The question splits in two, and the halves disagree
### For `docker compose` stacks, systemd is mostly redundant
**44 of 44** module `docker-compose.yml` files declare a container-level `restart:` policy —
`always` or `unless-stopped`, none missing. Docker's own daemon already restarts crashed
containers, independently of systemd.
`hal-module@.service` does not supervise the containers. It supervises the **`docker compose
up` foreground process**. It is a second layer on top of a restart policy that already
works. What it genuinely adds is narrower than it looks:
- one uniform verb for every module type — `systemctl --user start hal-module@X` rather than
remembering each module's compose invocation, relied on across a dozen call sites in
`meshware.ts:124-154`, `dev-env.ts:142-163`, `installer-core.ts`, `infra.ts:166`
- recovery when the `docker compose up --pull always` process itself dies — a failed image
pull, not a crashed container
- rate-limited restart (`StartLimitBurst=5`) so a broken stack does not spin
That is real, but it is a convenience layer, not a safety layer. **This half could go at
moderate cost.**
### For the 9 Node daemons, systemd is load-bearing
There is no alternative supervisor anywhere in the repo. No PM2, no forever, no nodemon, no
watchdog loop — all checked, zero hits. `Restart=always` is the only thing standing between
a crashed daemon and a dead node.
**This half is the actual question.**
---
## 3. The hard part is fate-sharing
The difficulty is not systemd. It is that **a supervisor must not share fate with what it
supervises**, and the codebase already has a scar from exactly this.
meshware cannot restart itself mid-request: killing the process before it ACKs the AMQP
message loses the message. So it defers its own restart by two seconds after closing the
connection (`modules/hal/meshware/daemon/src/cerebellum.ts:815-828`) — a commented
workaround for a problem that only exists because the thing being restarted is the thing
doing the restarting.
Any mesh-native supervisor inherits this recursively. Something has to be the outermost
always-alive process, and if it is written by the mesh, the mesh must supervise it, and so
on. The recursion only terminates at a process the mesh does not own. Today that is
systemd. **A "more mesh" supervisor that is itself a mesh process is not a smaller problem;
it is the same problem with a new name.**
This is the strongest argument for the status quo, and it is worth stating plainly before
looking at alternatives.
---
## 4. The option neither of us named
There is a third answer that terminates the recursion in something that is not systemd and
not written by us: **run HAL's own daemons as containers.**
Docker is already the outermost supervisor for 44 of 44 module stacks. It does not share
fate with the mesh. It already has restart policies, backoff, and a log store that
`log_tail` already speaks (`log-tail.ts:43-47` reads `docker logs` for Docker modules
today). Extending it from "the things the mesh runs" to "the mesh itself" is not new
machinery — it is applying machinery the mesh already trusts to one more case.
What that buys, beyond supervision:
- **Phase 0 stops being a translation.** A containerised node becomes the same shape as a
production node, rather than a local approximation with a systemd-shaped hole in it. The
divergence that `002` open question 3 worries about largely disappears.
- **`/services/` and `~/.config/hal/` stop being special.** Mounts, not host paths.
- **The GENESIS "dogfood everything" value gets easier**, not harder: the mesh's own
components would ship and run exactly like everything else it carries.
What it costs, honestly:
- **journald → docker logs** for the 9 daemons. `log_tail` already handles both, but
`systemd_journal` and the health checks that read unit state
(`modules/hal/mesh/health.sh:34-40`, `modules/hal/health/hal-health.sh:335-395`) would
need a container-aware path.
- **Boot start** becomes Docker's `restart: always` plus the Docker daemon being enabled at
boot — which is still one systemd unit, but the OS's own, not ours.
- **A container needs the host to be reachable** for anything that touches the node itself.
Some of these daemons exist precisely to write host files.
---
## 5. Why it cannot be all-or-nothing — and ADR 0001 already says so
Some of what runs under systemd today **cannot** be containerised, and the reason is
already in the domain model. ADR 0001:
> a non-human agent acts through a spawned session — a human agent acts through a shell or
> desktop
`hal/brain` has two modes (`modules/hal/brain/daemon/src/brainstem.ts:7-13`): a daemon mode
that is an AMQP relay, and a **cortex mode that is an MCP server over stdio for an
interactive Claude Code session**. The second is a human agent's modality. It runs in the
human's shell, on the human's node, against the human's `~/.claude`. Containerising it is
not a hard engineering problem, it is a category error.
The same holds for `desktop_notifications` and everything `hal/desktop-environment` touches.
So the line is not "systemd or not". It is:
| | belongs where |
|---|---|
| mesh daemons — meshware, coordinator, env-sync, thoughts, file-share, brain **in daemon mode** | supervisable by Docker; candidates to containerise |
| human-modality surfaces — brain **in cortex mode**, desktop notifications, desktop environment | on the host, by definition |
| module stacks | already Docker; systemd layer is the redundant part |
This split is not a compromise between the options. It is what the domain model implies,
and it is a decent sign that the model is doing work.
---
## 6. Options, with costs
| | option | cost | what it buys |
|---|---|---|---|
| **A** | **Keep systemd; systemd-in-container for Phase 0** | privileged containers, heavy images, slow iteration; local mesh keeps a shape production does not have | nothing changes in production; smallest change to the mesh |
| **B** | **Drop the `hal-module@` layer only** — let Docker's restart policies supervise stacks directly | reimplement uniform start/stop across ~12 call sites; lose rate-limited restart and pull-failure recovery | removes the redundant layer; does **not** solve Phase 0 on its own, since the daemons still need supervising |
| **C** | **Containerise the mesh daemons; Docker supervises** | container-aware `systemd_journal`/health; host access for daemons that write host files; the human-modality surfaces stay on the host regardless | terminates the fate-sharing recursion without writing a supervisor; makes local and production the same shape; Phase 0 becomes much less of a special case |
| **D** | **Write a mesh-native supervisor** | the fate-sharing recursion (§3), plus matching systemd's maturity — backoff, resource limits, clean SIGTERM (`noxflow/runtime` already depends on `TimeoutStopSec=60`) — on machines that are somebody's daily driver | most "mesh"; least justified by the evidence |
**D is the option the phrasing "more hal mesh approach" points at, and the evidence argues
against it.** Supervision is not a domain concern the mesh is better placed to solve than
the OS; the mesh's distinguishing feature is brokering capabilities, not restarting
processes. C gets the benefit D is reaching for — the mesh not depending on host-specific
init — without the recursion.
**C and B compose.** C is the one that pays for Phase 0.
Worth noting what is *not* in this table: the dozens of `systemctl` call sites that
configure the **host OS's own** units — NetworkManager, resolved, oomd, zram, docker.service,
fail2ban, sshd, zfs, across the vendor-firmware and power modules, `wireguard`, `sshd`, `zfs`
and others. Those are not HAL supervising itself; they are HAL configuring an Arch box. They
are out of scope for every option above and do not go away under any of them.
---
## 7. An incidental finding
Documentation describes an automatic node rescue: `hal-rescue.sh:20` states it is "triggered
automatically by `hal-health.timer` when hal-meshware is failed".
**It is not.** `hal-health.sh` contains no call to `hal-rescue.sh` (checked, zero matches),
and **no unit in the repository declares `OnFailure=`** (checked, zero matches). The only
real triggers are the manual `rescue_node` tool
(`modules/hal/sdk/src/tools/deploy-rescue.ts:77-92`) and running `install.d/rescue.sh` by
hand.
This is worth recording for two reasons. It weakens any argument that systemd-adjacent
self-healing is already wired — it is not. And it is another instance of the pattern this
whole refactor is about: **a documented mechanism that does not exist, believed because it
was written down.** `00-META/how-we-build.md` calls this out as a rule; here it is again,
found by grep.
---
## 8. Open question
**Which supervision model does the mesh adopt?** A, B, C, D or a combination.
**This no longer gates Phase 0.** When this was written, the local mesh was assumed to be
built from application containers, which forced the question — there is no natural way to
run an init system inside one. The decision of 2026-08-22 to build development nodes as
**system containers** (see [`03-DESIGN/01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md))
removes that pressure entirely: a system container runs a real init, so the existing model
works unmodified and the lab needs no answer here to exist.
What remains is the question on its own merits, which is worth keeping open because the
evidence above still holds: option B removes a layer that 44 of 44 module stacks have already
made redundant, and option C would let the mesh stop depending on host-specific init. Neither
is urgent. Both are now cheap to *try*, because there is somewhere to try them.
Option D — a mesh-written supervisor — remains the one the evidence argues against, for the
fate-sharing reason in §3.
## References
- [`002-local-mesh`](../002-local-mesh/analysis.md) — the effort this came out of
- [`02-DECISIONS/0001`](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) — agent modality, which
decides what cannot leave the host
- `modules/hal/meshware/daemon/src/cerebellum.ts:815-828` — the self-restart workaround
- `modules/hal/meshware/systemd/hal-module@.service` — the per-module Docker lifecycle
- `modules/hal/sdk/src/feature-handlers/systemd.ts` — detection, install, start
@@ -0,0 +1,67 @@
---
status: graduated
initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/01-mesh-and-transport.md, 03-DESIGN/01-to-be/01-end-to-end-testing.md]
became:
- 02-DECISIONS/0016-the-lab.md
- 02-DECISIONS/0016-the-lab.md
- 02-DECISIONS/0016-the-lab.md
- 03-DESIGN/01-to-be/02-scenario-declaration.md
- 03-DESIGN/01-to-be/01-end-to-end-testing.md
---
# 004 — Reproducing the mesh network in a lab
- **Initiated by:** jochen, 2026-08-22 — *"the most difficult part of our VM setup will be
the networking part"*
- **Areas touched:** `modules/wireguard`, `modules/dnsmasq-app`, `modules/traefik`,
`modules/mesh-ca`, `node_accessors`, `nodes.site` / `nodes.underlay_addr`.
## Summary
The network is **entirely generated from mesh-DB rows by module hooks**. `install.d` performs
no network configuration whatsoever — no WireGuard, no DNS, no firewall. That makes a faithful
lab primarily a *data* problem rather than a networking problem, and means the lab exercises
the real code path instead of a reimplementation of it.
One constraint decides whether the lab works at all: the WireGuard endpoint rule tests the
underlay address against an RFC1918 regex to decide reachability. **A simulated public segment
addressed from RFC1918 space silently prevents the mesh from forming** — no endpoint is written
for the hub, so nothing can ever initiate. The simulated public segment must therefore use
TEST-NET-3 (`203.0.113.0/24`).
With that one substitution the lab reproduces the production topology exactly, including the
case that is hardest to get right: a node that is publicly *named* but sits behind NAT, whose
endpoint the hub can only learn from a handshake.
Detail in [`analysis.md`](analysis.md).
## Settled
**The lab issues its own certificates.** Public names are certified by an ACME server on the
lab's wan segment; `.internal` names keep the mesh CA. The lab preserves production's two-CA
split rather than collapsing it, because a single-CA lab would hide any bug living in that
split. It also makes the router's port forward load-bearing — HTTP-01 must reach the
published-but-NATed node on port 80, so a broken forward becomes a reproducible certificate
failure instead of a mystery.
Requires one change: `caServer` is not set on the reverse proxy today, so it defaults to the
public authority's **production** endpoint. It must become configurable, defaulting to
production so real nodes are unaffected.
## Open
*Closed 2026-08-25.* The lab is stood up. The topology this effort described raises, and the
substitution it turned on — a simulated public segment addressed from documentation space
rather than RFC1918 — is enforced by the declaration validator before anything is raised
rather than left as a thing to remember.
The certificate conclusion above is carried by
[`01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md), which
specifies the lab's own ACME issuer on the public segment. It is **designed and not built** —
implementation state is a third axis, and the effort graduates on its conclusions, not on
their delivery.
One item leaves this effort without a home and is recorded here so it is not lost: the reverse
proxy does not set `caServer`, so it defaults to the public authority's production endpoint.
That is a fact about what runs today, not about the lab.
+203
View File
@@ -0,0 +1,203 @@
---
effort: 004-lab-network
updated: 2026-08-22
---
# Reproducing the mesh network in a lab
Established 2026-08-22 by reading the generating code and the live mesh DB. Every claim is a
file location or a queried row.
---
## 1. The network is data, not configuration
`install.d/mesh-init.sh` and `install.d/adopt.sh` perform **no network configuration at
all** — no WireGuard, no DNS, no firewall, no `/etc/hosts`. Every part of the network layer is
generated by module hooks from mesh-DB rows:
| layer | generated by | from |
|---|---|---|
| WireGuard interface + peers | `modules/wireguard/hooks/index.ts` (`postConfigure`) | `node_wg_keys`, `nodes.site`, `nodes.underlay_addr`, `module_env.WG_ADDRESS` |
| `.internal` name resolution | `modules/dnsmasq-app/hooks/index.ts` | mesh config peers → `internal_domain` + `wg_address` |
| public routing / vhosts | `modules/hal/sdk/src/vhost-gen.ts`, `feature-handlers/vhost.ts` | `vhosts:` manifests + `node_accessors` |
| internal TLS | `modules/mesh-ca/hooks/index.ts` | a singleton CA row in the mesh DB |
**Consequence:** a faithful lab is mostly a matter of writing the right rows. The network that
results is produced by the same code production runs, which is the difference between testing
the network and testing a model of it.
---
## 2. The constraint that decides whether the lab works
`modules/wireguard/hooks/index.ts:225-240` decides, per pair, whether to write an `Endpoint`:
```js
const isPrivate = (a) => /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
if (coLocated && underlay) Endpoint = `${underlay}:${port}` // same LAN
else if (underlay && !isPrivate(underlay)) Endpoint = `${underlay}:${port}` // public
// else: no Endpoint — the peer must initiate, and we learn its endpoint from the handshake
```
A simulated public segment addressed out of RFC1918 space — `10.200.0.0/24`, say — makes the
hub's underlay test as **private**. No spoke writes an `Endpoint` for the hub. Nothing can
initiate. **No handshake ever occurs and the mesh silently never forms**, presenting as a
WireGuard fault rather than an addressing choice.
**The simulated public segment must therefore be `203.0.113.0/24`** — TEST-NET-3, reserved by
RFC 5737 for documentation, guaranteed never to route on the real internet, and not matched by
that regex. The code then treats it exactly as it treats a real hosting provider address.
This is the single most important fact in this document.
---
## 3. The topology being reproduced
The shape below is what a mesh of this kind looks like: one node with a routable address, one
publicly named but behind a household NAT, one stationary workstation, one that roams.
Addresses use the documentation ranges of RFC 5737 and RFC 1918 throughout.
| node | profile | site | underlay | WG | accessors |
|---|---|---|---|---|---|
| `anchor` | server | `dc` | `203.0.113.10` (routable) | `10.10.0.1/24` | `anchor.example` (public, primary) + `anchor.internal` (lan) |
| `home-server` | server | `home` | `192.168.1.135` | `10.10.0.2/24` | `home-server.example` (public, primary) + `home-server.internal` (lan) |
| `workstation` | workstation | `home` | `192.168.1.250` | `10.10.0.3/24` | `workstation.internal` (lan, primary) |
| `laptop` | workstation | `NULL` | `NULL` | `10.10.0.4/24` | `laptop.internal` (lan, primary) |
**Hub election is by convention, not by flag.** The hub is the node whose `profile='server'`
*and* whose `WG_ADDRESS` begins `10.10.0.1` (`hooks/index.ts:188`). A lab must assign
`10.10.0.1` to the node it intends as hub or there will be no hub.
**`site` drives direct peering** (`hooks/index.ts:197-203`). Two nodes with the same non-null
`site` peer directly with a `/32`; everything else routes through the hub's `/24`. A `NULL`
site means roaming and hub-only — deliberately, because WireGuard has no failover and a more
specific `/32` route to a dead endpoint blackholes rather than falling back.
The four interesting pairs, all of which the lab must reproduce:
| pair | behaviour | branch taken |
|---|---|---|
| anything → `anchor` | `Endpoint` written | underlay non-private |
| `home-server` ↔ `workstation` | direct peer, LAN endpoints, keepalive | co-located |
| **`anchor` → `home-server`** | **no `Endpoint`; learned from handshake** | not co-located, underlay private |
| `laptop` → anything | hub only, always initiates | `site` is `NULL` |
The third is the one worth building the lab for. The code comments at `hooks/index.ts:206-224`
record what it cost to get right: testing `profile === "server"` was tried and was wrong,
because a home-hosted node **is** a server yet is not publicly reachable — *"role does not
imply reachability; the address does."* An earlier version aimed the hub at that node's public
name, which hairpinned off the household NAT: 1.77 MiB sent, 0 B received, no handshake.
---
## 4. The lab
```
br-wan 203.0.113.0/24 TEST-NET-3 — non-private, so the code treats it as public
│
├── hub 203.0.113.10 profile=server site=dc WG 10.10.0.1
│
└── router VM 203.0.113.1 / 192.168.1.1
│ NAT, plus one forwarded port to reproduce a published-but-NATed node
│
br-lan 192.168.1.0/24 identical to production, same host addresses
├── a 192.168.1.135 profile=server site=home WG 10.10.0.2
└── b 192.168.1.250 profile=workstation site=home WG 10.10.0.3
c — attach to br-lan, or br-wan ("away"), or detach ("asleep")
underlay NULL profile=workstation site=NULL WG 10.10.0.4
```
Kept **byte-identical** to production: the LAN subnet and its host addresses, and the entire
WireGuard plan. Only the public segment is substituted, and only because it must be.
**The router earns its own VM.** It is what makes the published-but-NATed case real: that node
is reachable from outside only through a forwarded port, and the hub must learn its endpoint.
It also gives somewhere to break things — drop the forward and observe whether the mesh
notices or whether the public name simply stops working.
**Names.** A resolver on the wan side is authoritative for the public zone. `.internal` names
need nothing extra: `dnsmasq-app` generates them from mesh config on each node, and writes an
`/etc/hosts` block as a floor underneath, because a node must reach the mesh DB before its own
DNS exists.
---
## 5. What a node needs before any of this works
Rows in the mesh DB — `nodes` (`name`, `user_name`, `profile`, `site`, `underlay_addr`),
`node_accessors`, `module_env.WG_ADDRESS` (the hook hard-fails without it,
`hooks/index.ts:114-116`), and `node_modules` assigning at least `wireguard`, `dnsmasq-app`,
`mesh-ca`, and `traefik` where it serves.
On disk beforehand, because the node must reach the mesh DB before it can read any of the
above: registry database and object-store host and credentials, plus an npm token. This
ordering — contact the mesh before the mesh has configured you — is itself worth reproducing,
and is why the `/etc/hosts` floor exists.
Everything else is generated: the WireGuard keypair locally (the private key never leaves the
node; the public key is published to `node_wg_keys`), the peer list, the DNS records, the TLS
leaf.
---
## 6. Certificates — the lab issues its own
**Settled 2026-08-22: the lab runs its own ACME issuer.**
Public certificates use ACME **HTTP-01** via the reverse proxy, which requires genuine public
reachability, so an isolated lab cannot use the real issuer. Rather than forgo certificate
testing, the lab stands up an ACME server on its wan segment.
**The lab keeps production's two-CA split rather than collapsing it.** Production issues
public names from a public authority and internal names from the mesh CA; a lab with one CA
would hide any bug living in that split. So:
| | production | lab |
|---|---|---|
| public names | a public ACME authority | a test ACME server on the wan segment |
| `.internal` names | `mesh-ca` | `mesh-ca`, unchanged |
**A test issuer is the right shape, not a shortcut.** Purpose-built ACME test servers
deliberately vary their behaviour — validation timing, nonce handling, chain composition — to
expose assumptions a well-behaved authority would let pass. A lab CA that is *too* polite
tests less than the real thing, not more.
### It also exercises the port forward
HTTP-01 means the issuer must reach the node being certified on port 80. In the lab:
- the hub is directly reachable on the wan segment — straightforward
- **the published-but-NATed node is reachable only through the router's forwarded port**
So certificate issuance for that node passes only if the forward is correct. That is exactly
why its certificate works in production, and it makes "the forward is missing" a reproducible
failure rather than a mystery.
### Required change: `caServer` must be configurable
`modules/traefik/docker-compose.yml:17-19` sets the challenge entrypoint, the contact address
and the storage path — but **no `caServer`**, so Traefik defaults to the public authority's
*production* endpoint. Pointing the lab at its own issuer requires adding a `caServer` flag
fed by an environment value, defaulting to production so real nodes are unaffected and the lab
overrides it per node.
Worth noting independently of the lab: aiming at the production endpoint rather than a staging
one means every certificate experiment on a real node consumes production issuance quota, and
a retry loop can exhaust it for a week. The lab issuer removes that exposure.
---
## 7. Incidental finding: `scope:` is read by nothing
Several manifests declare `scope: public` on firewall rules — `wireguard`, `traefik`, `gitea`,
`mailu`, `qbittorrent`. It is **not part of the rule type** (`module-registry.ts:17-29`) and is
**referenced by no code** in the firewall path. Real scoping is done with `from:`, as
`modules/unifi/module.yml:52-93` does deliberately.
So a manifest can appear to restrict a port to the public scope and in fact restrict nothing.
This is the same shape as the rule in `00-META/how-we-build.md` — *an unenforced rule is
indistinguishable from a wrong one, and costs more, because people believe it.*
@@ -0,0 +1,87 @@
---
status: graduated
initiated: 2026-08-23
touches:
- 02-DECISIONS/0009-modules-and-the-graph.md
- 03-DESIGN/00-as-is/10-module-catalogue.md
- 03-DESIGN/00-as-is/02-modules-and-manifests.md
became:
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0009-modules-and-the-graph.md
---
# 005 — Which domains the catalogue groups into
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) settles
that modules outside the platform core are grouped by domain rather than by single function,
and deliberately does not settle the list. This effort settles the list — and, first, tests
whether the premise survives measurement.
## What is being investigated
Eighty-nine modules sit outside the platform core. The question is which of them belong
together, and the method is evidence rather than intuition: **which modules actually change
together**, measured across the full history of the code repository.
## Why
The argument in ADR 0022 is that the catalogue's shape records what was installed rather than
what anything is for — that four modules constituting "how a node is reachable" have no
relationship the mesh can see, so a change to connectivity is made four times.
That argument is testable. If those modules genuinely change together, the grouping is
justified by more than tidiness. If they do not, the premise needs revising before a list is
drawn from it.
## What it touches
The catalogue's shape, the manifest, and — for one candidate grouping — the provisioning
reference itself, since a requirement names a provider **module**. Grouping providers would
change what a consumer names.
## Status
Measurement is done and is in [`analysis.md`](analysis.md). It **partly contradicts the
premise**, in a way that narrows the effort usefully:
- Non-platform modules overwhelmingly change **alone** — 10% of commits touch more than one,
and 50 of 89 never co-change with anything.
- Two clusters do exist. One of them, reachability, holds up as a domain.
- The other, the provisioned infrastructure providers, co-changes for a reason that argues
**against** grouping rather than for it.
The remaining work is the list itself, for the modules where grouping is justified, plus the
open questions below.
## What it became
*Closed 2026-08-28.* Three of the four questions are answered, and by records that did not cite
this effort — which is why it stayed open after being resolved.
**Whether provider modules group at all** — *no.*
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md):
there is no `networking` thing to install, there are concrete modules named individually. Folders
assert relationships; edges record them. *Provider* stops being a category at the same time.
**Whether "group or leave" is even the right pair of options** — *it was not*, and that is the
useful finding. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)
reframes it: things that change together share an **authority**, not a package. This effort's own
measurement is what that record rests on — reachability being the *only* place modules genuinely
co-change is why connectivity is a context and why nothing else needed one.
**What to do with the ~50 modules that co-change with nothing** — *nothing.* They are modules.
Grouping is a tag and a query over the graph, neither of which anybody keeps true by hand.
**What remains is a sequencing question, not a grouping one**, and it moves rather than closes:
*whether applications leave the monorepo before or after they group* is
[research 009](../009-migration/00-overview.md)'s, because it is about how to get from here to
there rather than about what the shape is.
## Open (superseded by the above) questions
| Question | Why it is open |
|---|---|
| Whether provider modules group at all, and if so what a consumer's requirement names instead of a module. | The provisioning reference is load-bearing; getting it wrong is expensive. Opinion and evidence in the analysis; not yet decided. |
| Whether applications group into domains now and leave the monorepo later as a unit, or leave first. | Decided in principle — group first, then split — but the migration order has real cost either way. |
| What to do with the ~50 modules that co-change with nothing. | The evidence gives no grouping signal for them at all. That may mean they are correctly sized already. |
| Whether "group or leave" is even the right pair of options. | [Research 006](../006-mesh-from-scratch/code-skeleton.md) finds a third fate — **absorbed into the node host**, ceasing to be a module at all — and argues it is the correct answer for the reachability cluster this effort measured. If so, the cluster this effort found is evidence for absorption rather than for grouping. |
+144
View File
@@ -0,0 +1,144 @@
---
effort: 005-domain-grouping
updated: 2026-08-23
---
# Which modules actually change together
## Method
Every commit in the code repository's main branch that touches the module catalogue, reduced
to the set of modules it touched. Platform-namespace modules are excluded — their
decomposition is settled by
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md). Modules that no
longer exist are excluded, because pre-rename names dominate the raw signal and describe a
catalogue nobody works in.
Commits touching more than eight modules are excluded from pair counting: a sweep across
forty modules says the sweep happened, not that the modules are related.
## Finding 1 — these modules overwhelmingly change alone
| Measure | Value |
|---|---|
| Non-platform modules today | 89 |
| Commits touching at least one | 312 |
| Commits touching two to eight together | 33 — **10%** |
| Modules that never co-change with anything | **50 of 89** |
Nine commits in ten touch exactly one module. More than half the catalogue has never been
edited alongside anything else.
This is the first thing a grouping proposal has to survive, and it is not what the premise
predicts.
## Finding 2 — two clusters exist
Everything above two co-changes falls into one of two groups.
**The provisioned infrastructure providers.** The densest cluster: the relational stores with
each other and with the cache, the broker, the object store and the image registry — a
strongly connected set, six to two co-changes per pair.
**Reachability.** The reverse proxy with the resolver, the firewall and the VPN; the firewall
with the intrusion filter. Three to two co-changes per pair.
Outside those: a torrent client with the VPN, twice. A login manager with a media player,
twice. Nothing else reaches two.
## Finding 3 — the provider cluster is manifest churn, not cohesion
This is the result that matters, and it is only visible by reading the commits rather than
counting them.
Every multi-provider commit is a **cross-cutting change to the machinery, applied N times**:
| Date | What the commit did |
|---|---|
| 2026-04-15 | modules own their tools — decentralised tool serving |
| 2026-04-24 | filesystem-driven feature detection; manifest boilerplate removed |
| 2026-04-27 | remove runtime dependencies that were only build dependencies |
| 2026-05-01 | hooks move into their own directory with their own package |
| 2026-05-05 | hooks renamed to a stage-and-feature convention; legacy install scripts removed |
| 2026-08-06 | verify **by shape in the SDK**, not by copying a script into every module |
| 2026-08-11 | bind declared volume paths that leaked an anonymous volume per deploy |
| 2026-08-23 | scope services to the local network and mesh; respect the profile gate |
**Not one of them is a change to what a database is.** They are all "the manifest contract
changed, therefore every manifest changed".
Two consequences follow.
Merging the providers into one module would not have prevented a single one of these commits.
It would have made the same edit land in one file rather than six — which is a diff-size
improvement, not a boundary.
And the history already shows the correct fix being applied, repeatedly and successfully:
*verify by shape in the SDK, not by copying a script into every module*. When the same edit
must be made in every provider, the answer that worked was **moving the concern into the
machinery**, not merging the modules that carry it.
Co-change here measures coupling to the manifest contract. It does not measure domain
cohesion, and using it as though it did would group the catalogue by which modules are most
boilerplate-heavy.
## Finding 4 — reachability holds up
The same reading applied to the reachability cluster gives a different answer. Most of its
twenty-five multi-module commits are the same machinery churn — but not all, and the
remainder are genuine:
| Date | What the commit did |
|---|---|
| 2026-07-29 | let the mesh's proxy coexist with another listener on the same port |
| 2026-08-03 | derive public split-DNS from node accessors so requests stop hairpinning |
| 2026-08-06 | firewall mesh-only by default, public by declaration |
Each is one intent — *change how a node is reachable* — landing across the proxy, the
resolver, the firewall and the VPN together. That is exactly the shape ADR 0022 describes, and
it is the only place in the catalogue where the measurement finds it.
The 2026-08-23 scoping commit is the sharpest case: it spans the reachability cluster **and**
two providers, because "which network is this exposed on" is a reachability question asked of
a database.
## What this means for ADR 0022
The record's principle stands, and its scope needs narrowing. Grouping by domain is:
- **Justified by evidence** for reachability. One intent, several modules, repeatedly.
- **Argued against by evidence** for the providers. The coupling is to the manifest contract,
and the demonstrated fix is to move the concern into the machinery.
- **Unsupported either way** for the fifty modules that co-change with nothing. Silence is not
evidence of independence — many are simply rarely touched — but there is no measured basis
for grouping them, and a proposal that groups them is drawn from intuition. It should say so.
The catalogue's shape is still wrong in the way the record describes. Measurement says
grouping is the right fix in fewer places than the record implies, and that a second fix —
moving cross-cutting concerns into the machinery — accounts for most of what looks like
grouping pressure.
## Recommendation on the provisioning reference
Asked directly, and stated as an opinion because it is not yet decided.
**Do not group the provider modules.** Three reasons, in order of weight:
1. **The evidence for grouping them is the wrong evidence.** Finding 3.
2. **A requirement names a provider module.** Grouping providers means a consumer names a
resource type and something else chooses the implementation. That is not a folder move; it
is implementation selection, a substantially larger design with its own failure modes, and
nothing currently asks for it.
3. **It would hide which implementation serves a requirement** — the one place the mesh most
needs to be explicit, and precisely the indirection ADR 0022 warns grouping causes.
A provider module is already exactly one purpose: it provisions one resource type. That is a
boundary, not an accident of installation.
## Still to do
- Draw the list for the cases where grouping is justified, and say plainly which entries rest
on measurement and which on judgement.
- Decide the ~50 silent modules: correctly sized, or unmeasured?
- Test the reachability grouping against the migration cost — the modules in it are among the
most-changed in the catalogue, so churn during a move is not hypothetical.
@@ -0,0 +1,94 @@
---
status: active
initiated: 2026-08-23
touches:
- 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 03-DESIGN/00-as-is/00-overview.md
- 03-DESIGN/01-to-be/00-work-breakdown.md
became: []
---
# 006 — The mesh designed from nothing
## What is being investigated
What the mesh would look like if it were laid out today, with the requirements known and none
of the accumulated shape — expressed as a **skeleton**: repositories at the root, modules
inside them, and whatever turns out to be the right leaf unit below that.
The deliverables are [`skeleton.md`](skeleton.md) — tiers, repositories and the four design
moves — and [`code-skeleton.md`](code-skeleton.md) — the tier test, what a module looks like on
disk, and where today's catalogue lands.
## Why
Every structural decision so far has been a **correction**: eight contexts replacing thirty-three
modules ([ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md)), domains
replacing single-function modules
([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)). A
correction inherits the frame of the thing it corrects, and two of the mesh's oldest problems
look unsolvable from inside that frame:
- **The bootstrap circularity.** The mesh needs a database, a bus, a registry and an identity
provider. Those are modules the mesh installs. The mesh cannot install them before it exists.
This has been worked around repeatedly and never designed away.
- **Participation requires privilege.** Everything assumes root on a machine whose packages and
services the mesh owns. A phone cannot participate on those terms, and neither can a machine
someone else administers.
Designing from nothing is a way to find out which parts of the current shape are requirements
and which are residue.
**And there is more residue than expected, from a knowable source.** This began as a
**dotfiles repository** — the first two days of history adopt dotfiles, add per-node dotfile
overrides, and introduce service symlinking with an ignore file. The flat one-directory-per-tool
catalogue, linking rather than copying, adoption of already-configured machines, per-node
overrides, and the desktop modules are all inherited from that, not chosen for a mesh. Recorded
in [`03-DESIGN/00-as-is/10-module-catalogue.md`](../../03-DESIGN/00-as-is/10-module-catalogue.md).
That makes this effort's question sharper than "what would we do differently": much of what
looks like design is a generalisation of *place files on my machines*, never revisited because
it was never stated as an assumption.
## The requirements this is designed against
Stated by the operator, recorded here so the skeleton can be checked against them rather than
against taste:
1. The mesh manages multiple computers — **full control**, through modules installed to nodes.
2. Mesh state lives in a **database**: which modules on which nodes, logs, configuration.
3. Configuration has **several touchpoints** — tool surface, web interface, others — all hosted
by the mesh itself.
4. **Connectivity** is core: every node reachable from every other over a shared overlay, some
nodes publicly exposed, firewalls configured.
5. The mesh **hosts applications** — and requires some of them itself. This is the circularity.
6. **Arch Linux only for now**; ideally any device, including phones, on lighter terms.
7. The end goal is to **operate an IT company** on it — development, design, deployment, full
circle, self-hosted. Personal cloud infrastructure.
8. **Agents make it self-improving and self-healing.**
9. It is **end-to-end testable on one machine**
([ADR 0016](../../02-DECISIONS/0016-the-lab.md)).
## Status
A first skeleton exists, with four design moves that the current shape does not have. It is
`active` because two of them are unproven and one contradicts a record that is already
accepted.
**Finding worth stating up front:** [ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md)
names nine bounded contexts and **none of them owns connectivity** — no overlay, no resolution,
no firewall, no ingress. Requirement 4 has no home in the accepted decomposition, while
[research 005](../005-domain-grouping/analysis.md) found reachability to be the *only* part of
the catalogue where modules genuinely change together under one intent. The skeleton adds it.
## Open questions
| Question | Why it is open |
|---|---|
| Does the record — the event log contexts integrate through — belong to the substrate or the control plane? | It is infrastructure by shape and domain by content. Placing it wrong reintroduces a circularity. |
| One repository per tier, or per context? | Already open from ADR 0001 as "catalogue destination — one repository or many". The skeleton assumes per tier and does not settle it. |
| ~~Does an unprivileged node earn a place in the inventory, or only a presence?~~ | **Answered 2026-08-25** by the operator: a node is a *managed machine inside the mesh*, not an unprivileged something — and a disconnected node is still a node, in a different situation. The question posed a class distinction; the answer is that there is none, and what varies is **state**. Recorded as [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md). |
| ~~Does absorbing overlay, filtering, packages, supervision and the container runtime make the host too large?~~ | **Answered 2026-08-25** — [`host-size.md`](host-size.md). Measured: the absorption is smaller than the machinery that already applies state, and eight of ten adapters already carry no dependency. The risk is not size but direction, and it is two modules wide. The claim survives with its scope corrected — the host carries one concern, *apply declared state on this machine*, of which the six are instances. Recorded as [ADR 0005](../../02-DECISIONS/0005-the-node-host.md), designed in [`05-the-node-host.md`](../../03-DESIGN/01-to-be/05-the-node-host.md). |
| ~~Four substrate services or five?~~ | **Answered conditionally**, which is the honest form — [`07-the-foundation.md`](../../03-DESIGN/01-to-be/07-the-foundation.md). The substrate is *what the control plane consumes and cannot grant itself*. The identity provider qualifies only if the control plane delegates authentication; if it authenticates natively it is an ordinary hosted service. The count follows from a decision not yet taken, and asserting four was asserting that decision. |
| Does `feature` survive? | The skeleton splits it in two and argues the conflation is what makes the delivery pipeline hard to reason about. Unproven. |
@@ -0,0 +1,272 @@
---
effort: 006-mesh-from-scratch
updated: 2026-08-23
---
# The code skeleton
[`skeleton.md`](skeleton.md) laid out tiers and repositories. This is the level below: what a
module looks like on disk, and — the question that forced a correction — where a given piece of
today's catalogue actually lands.
## The tier test
A decision procedure, so placement is answerable rather than argued. Ask in order; first match
wins:
1. **Does it apply state on a machine?** → tier 0, inside the host.
2. **Can the control plane *start* without it?** If *no* → tier 1, substrate. Note the verb:
*start*, not *function fully*. A capability the control plane loses without something is not
the same as a thing it cannot come up without — see "becoming self-hosting" below.
3. **Does it decide what should be true across nodes?** → tier 2, a control-plane context.
4. **Is it a way to talk to tier 2, holding no logic of its own?** → tier 3, a surface.
5. **Otherwise** → tier 4, a workload.
## Worked example — where postgres ends up
The obvious answer is "twice": once as the mesh's own database in the substrate, once as a
hosted database in the catalogue. That answer is wrong, and seeing why fixes something.
Run the test. *Can the control plane exist without a relational store?* No. **Postgres is
tier 1.** It lives once:
```
mesh-substrate/store/postgres/
```
There is no second copy in the catalogue, because the thing that differs between the mesh's own
database and a project's database is **not the module**. It is how that instance is brought up:
| | The mesh's own instance | A project's database |
|---|---|---|
| Brought up by | the host, from the pinned bundle, with no control plane present | the ordinary delivery and provisioning path |
| Declared in | `mesh-substrate/bundle.yml` | the consuming module's `requires:` |
| Exists because | the control plane cannot start without it | something asked for it |
Same module, two roles. **Tier is a property of the module** — what must exist before what — and
**the bundle is a property of the mesh's own instance.**
This is also legal under the dependency rule, which is worth checking rather than assuming: a
tier-4 workload that requires a database depends on tier 1, which points *downward*. The
inverse — substrate reaching into the catalogue for a module — would not be, and is the shape
the naive "twice" answer would have created.
The same reasoning places the rest of the substrate: the bus, the object store, the image
registry. Each fails step 2, each lives once, each is pinned. That is the whole substrate: four
services, and the deliberate absence of a fifth.
**The identity provider was raised as a boundary case and is settled: it is not substrate.**
The mesh does not require one — tier 2 authenticates its own callers natively, and an identity
provider is a service the mesh hosts like any other. The substrate is four services, not five.
The test earned its keep here by turning a vague unease into one answerable question — *does the
control plane delegate authentication?* — rather than a debate about how important identity
feels.
## Becoming self-hosting — the forge and the registries
Self-improvement means the mesh hosts the things it improves itself with: a forge, an image
registry, a package registry. The obvious worry is that these duplicate — a `mesh-gitea`
for the mesh and a `gitea` for everyone else. **They do not, and the reason is worth stating
carefully, because it is the same reason the bootstrap keeps failing today.**
### They are not substrate
Run the test with the sharpened verb. *Can the control plane start without a forge?* **Yes.** It
comes up, holds inventory, answers questions and manages nodes with the modules it already has.
What it cannot do is **change itself**. That is a capability, not a precondition.
So: forge, image registry and package registry are **tier 4 workloads**. One module each, in the
catalogue, exactly like the media server. There is no mesh-specific copy.
This is not a technicality. It buys a property worth having: **if the forge dies, the mesh keeps
running.** Nodes stay managed, services stay up, only self-modification stops. Putting the forge
in the substrate would make losing it fatal, for no gain.
### But delivery needs them — is that not an upward dependency?
It would be, stated naively, and that would break the one rule the whole skeleton rests on.
It is resolved the way the constitution already says to resolve it — **depend on abstractions,
not on concrete dependencies**. Tier 2's delivery context does not require *the forge module*.
It declares requirements:
| Delivery requires | Satisfied by |
|---|---|
| a source of record for module code | whichever module provides it |
| somewhere to publish images | whichever module provides it |
| somewhere to publish packages | whichever module provides it |
| somewhere to put build artifacts | the substrate's object store |
Tier 2 defines the requirement; tier 4 provides the implementation; the binding is data. The
dependency points **downward from the provider to the interface**, which is legal, and the
control plane never names a concrete module.
The mechanism for this already exists and is the mesh's most valuable one: **provisioning**. A
module declares what it provides; a consumer declares what it requires; the mesh binds them.
The only new idea is that **the control plane is itself a consumer** — it has requirements, and
they are satisfied the same way a workload's are.
That generalisation is significant enough to need its own study, and is not settled here.
### Self-hosting is a state the mesh reaches, not a precondition
This is the part today's mesh gets wrong, and it explains a recurring class of pain.
A first node comes up from **pinned external artifacts** — upstream images, by digest, carried
in the bundle. It has to: the mesh's own registry does not exist yet, and cannot. The mesh at
this point is running and manages nodes, and is not yet self-hosting.
Self-hosting is then **reached**: the forge is installed as an ordinary workload, the mesh's own
source moves into it, the registries come up, and delivery's requirements are re-bound from
external providers to internal ones. From that point the mesh builds and deploys itself.
Stated as a lifecycle:
```
pinned external artifacts ─► mesh runs, manages nodes
│
│ forge + registries installed as workloads
│ delivery's requirements re-bound
▼
mesh builds and deploys itself
```
**Today's mesh assumes the second state from the first moment.** Its source, its packages and
its images are all expected to be self-hosted before there is anything to host them — which is
why raising a first node needs a script that exists solely to paper over the impossibility, and
why that script is the least-exercised path in the system.
Making the transition explicit has a second benefit: it is reversible. A mesh whose forge is
broken can re-bind delivery to external providers and keep improving itself while it repairs
the forge. Today that escape hatch does not exist, because the dependency is not expressed
anywhere it could be changed.
### So, concretely
One `gitea` module. One image-registry module. One package-registry module. Each a tier-4
workload. The mesh's own instances are distinguished from any other instance **by what they are
bound to, not by being different modules** — precisely the same answer as postgres, arrived at
by the same test.
## The five fates of a module
Research 005 asked whether a catalogue module should be **grouped into a domain** or **leave
the repository**. Working the tier test across the catalogue surfaces a third answer that
neither option covers, and it is the most common one.
| Fate | Means | Examples from today |
|---|---|---|
| **Absorbed into the host** | It is not a module at all. It is part of what "managing a machine" means, and belongs in tier 0. | overlay membership, packet filtering, package management, service supervision, container runtime, filesystem management |
| **Substrate** | The control plane cannot exist without it. Pinned, host-applied. | relational store, bus, object store, image registry |
| **Control-plane context** | It decides something across nodes. | connectivity policy, inventory, delivery, provisioning, observability |
| **Workload module** | The mesh hosts it. Grouped per [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md). | media library, desktop session, collaboration tooling |
| **Leaves the repository** | A standalone application, per [ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md). | the applications identified in research 005 |
**The first fate is the finding.** Research 005 measured the reachability cluster — proxy,
resolver, firewall, overlay — as the only place in the catalogue where modules genuinely change
together under one intent. The skeleton explains *why*: they are not four modules that ought to
be one domain module. They are four facets of one thing the host should own, currently
expressed as modules because a module was the only unit available.
Under this skeleton the overlay module and the firewall module **stop existing**. The host holds
membership and applies filtering; tier 2 decides the policy; the swappable backends stay
modules. That is a different and better answer than grouping them, and it was not visible from
inside the current frame.
It also partly answers research 005's other open question — the fifty modules that co-change
with nothing. Several are host concerns rather than domains: package management, container
runtime, filesystem tooling. Silence was the right signal after all; the wrong conclusion was
that grouping was the only available fix.
## What a module looks like on disk
Per [`skeleton.md`](skeleton.md) Move 4, a module declares **parts** — independently selectable
pieces of desired state — and produces **artifacts** — things built once per version.
```
<module>/
module.yml identity, what it provides, what it requires,
which host profiles it can land on
parts/
service/ desired state: container, volumes, exposure
provisioner/ how it grants its resource to consumers
migrations/ its own persistent state
tools/ capabilities it contributes
artifacts/
<name>/ source for something built and published
```
A module with no artifacts consumes an upstream image and builds nothing. A module with no
parts is not a module.
Worked through for the substrate's relational store:
```
mesh-substrate/store/postgres/
module.yml provides: database · profiles: [managed]
parts/
service/ the container, its volume, its network exposure
provisioner/ grants a database and role to a consumer
migrations/ none — it holds no state of its own
artifacts/ none — upstream image, pinned by digest in bundle.yml
```
## The tree, at file level
```
mesh-host/ TIER 0
cmd/host/
internal/
apply/ reconcile declared state
inventory/ what this machine is and can do
link/ outbound connection to the control plane
overlay/ membership: address, keys, tunnel
filter/ packet filtering from tier-2 policy
packages/ package management
services/ supervision
containers/ container runtime
store/ embedded local state
profile/ managed · user · edge
substrate.lock pinned tier-1 descriptor
mesh-substrate/ TIER 1
bundle.yml the pinned set, by digest
store/postgres/
bus/<broker>/
objects/<object-store>/
images/<registry>/
mesh-control/ TIER 2
record/ the event log contexts integrate through
inventory/ nodes · modules · assignments · versions
config/ settings · secrets · derivation
connectivity/ addresses · resolution · exposure · filtering · certificates
provisioning/ grants between modules
delivery/ source → artifact → node
observability/ health · logs · metrics
identity/ agents · humans · services · authorisation
work/ tasks · workflows · runs
knowledge/ memory · documents · retrieval
api/ the one interface surfaces speak to
mesh-surfaces/ TIER 3
tools/ web/ cli/
mesh-catalog/ TIER 4
<domain>/<module>/ layout as above
mesh-lab/ mesh-sdk/ hq/ (company-scoped — ADR 0019)
```
## What this does not settle
- **The identity boundary case.** Four substrate services or five.
- **Where the record lives.** Still the open question from
[`00-overview.md`](00-overview.md), and the tier test does not resolve it: the record is
needed by tier 2 and is *of* tier 2, which is exactly the shape that produces a circularity.
- **Whether absorbing into the host makes the host too large.** Six internal concerns is already
a lot for a binary whose whole argument is that it has no dependencies. The counter-argument
is that each is small and none can be optional — but this is the skeleton's biggest unproven
claim, and it should be tested by writing the host's interface before anything else.
- **The migration.** Nothing here says how today becomes this.
@@ -0,0 +1,126 @@
# Is the host too large?
The skeleton absorbs overlay membership, packet filtering, package management, service
supervision, the container runtime and filesystem management into tier 0, and
[`00-overview.md`](00-overview.md) calls this *"the skeleton's biggest unproven claim. A binary
whose whole argument is that it has no dependencies now carries six concerns."*
This is that claim, measured.
## Method
Against `origin/main` of the monorepo at 2026-08-25 — read through git refs rather than a
checkout, which sits on a feature branch 1031 commits behind with uncommitted work.
For each module that implements one of the six concerns: how much code it is, and **what it
depends on to do its job**. The second question turned out to be the one that matters.
## Finding 1 — by size, the concern is misplaced
| Absorbed into the host | Lines |
|---|---|
| `dnsmasq-app` | 706 |
| `traefik` | 426 |
| `ufw` | 385 |
| `mesh-ca` | 353 |
| `wireguard` | 313 |
| `incus` | 285 |
| `package-manager` | 118 |
| `zfs` | 67 |
| `fail2ban` | 60 |
| `docker-app` | 42 |
| **total** | **2 755** |
| Machinery that already applies state on a node | Lines |
|---|---|
| `hal/meshware` | 1 829 |
| `hal/env-sync` | 626 |
| `hal/config-sync` | 604 |
| **total** | **3 059** |
**Everything being absorbed is smaller than the machinery that already exists to apply it.**
The six concerns are not six subsystems; they are ten thin adapters averaging 275 lines, most
of which is rendering a config file and running a command.
The host is not a new large thing. It already exists, spread across three core modules, and
the absorption adds less code than those three already contain.
Size is therefore the wrong axis, and the open question asked about the wrong thing.
## Finding 2 — the real risk is direction, and it is narrow
What each adapter needs in order to decide what to write:
| Module | Gets its inputs from | Applier only? |
|---|---|---|
| `ufw` | `~/.hal/modules/*/module.yml` on the local disk | yes |
| `dnsmasq-app` | `process.env.DNSMASQ_*`, derived centrally by `env-sync` | yes |
| `fail2ban`, `mesh-ca`, `package-manager`, `docker-app`, `incus`, `zfs` | local state and declared env | yes |
| `wireguard` | **direct `pg` connection** — `nodes`, `node_accessors`, `node_wg_keys`, `module_env` | **no** |
| `traefik` | **direct `pg` connection** — `nodes`, `mesh_ca` | **no** |
**Eight of ten are already pure appliers.** They receive derived state and put it on the
machine. Absorbing those into tier 0 moves no dependency at all — it moves code that already
has none.
**Two reach upward.** `wireguard` and `traefik` open a connection to the control plane's
database and compute their own configuration from it. Absorbing them *as they are* would put a
Postgres client and knowledge of the mesh schema inside tier 0 — an upward dependency, which
is precisely what
[the tier rule](skeleton.md) forbids and what the entire bootstrap argument rests on.
So the danger in the absorption is real, and it is two modules wide rather than six concerns
wide.
## Finding 3 — the split has already been happening, unnamed
`dnsmasq-app` is the same *kind* of module as `wireguard`: it needs every node's addresses and
names. It does not query for them. Its own comments record why:
> *"the mesh DB already holds [this] in `node_accessors` and the WireGuard address, duplicated
> by hand on all four nodes. Renaming the namespace then meant editing four override rows
> nobody [knew about]."*
and
> *"now derived from `node_accessors`"*
That is one module having already made the move the skeleton proposes — deciding centrally,
applying locally — for the ordinary reason that hand-duplicated state went wrong. Nobody named
it as an architectural direction; it was reached by fixing a bug.
Eight of ten adapters are on the far side of that migration. Two are not.
## What this means for the open question
**The absorption is not a move. It is a split, and it is mostly already done.**
The question "does the host become too large" assumed six concerns would arrive whole. They do
not. Each divides:
- **deciding** — what this node's overlay, names, exposure and filtering should be, which needs
every other node and therefore belongs in tier 2;
- **applying** — putting that on the machine, which needs root and locality and therefore
belongs in tier 0.
Tier 0 absorbs the applying. That is ~2 755 lines today, most of it already dependency-free,
against 3 059 lines of apply machinery the host needs regardless.
**The claim survives, with its scope corrected.** The host does not carry six concerns; it
carries one — *apply declared state on this machine* — of which the six are instances. That is
the skeleton's own tier-0 test (*"does it apply state on a machine?"*) applied to itself.
## What remains open
- **The two unsplit modules are the two hardest.** Overlay needs every node's key, address,
site and endpoint reachability; the proxy needs certificates and every node's exposed names.
These are where "derive centrally, apply locally" is most work, and neither has been done.
The measurement says the design is right; it does not say the migration is cheap.
- **Six concerns is still six vocabularies.** Absorbing them adds no dependencies but does add
surface: the host must know what a WireGuard peer, an nftables rule, a package, a unit, a
container and a dataset *are*. Nothing here measures that cost, and it is the residue of the
original worry.
- **What the host must carry versus what it must find.** The host manages `wg`, `nft`,
`pacman`, `docker`; it does not contain them.
[Issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md) is
exactly this question and is unresolved.
@@ -0,0 +1,281 @@
---
effort: 006-mesh-from-scratch
updated: 2026-08-23
---
# The skeleton
Repositories at the root, modules inside them, parts at the leaf. Four tiers, and a dependency
rule that only points downward.
## What a tier is
A tier answers one question: **what has to exist before this can exist?** It is not importance,
and it is not a layer in the networking sense. It is bootstrap order, made explicit.
Walk a bare machine to a running mesh and the tiers fall out of the story:
```
bare machine
│ one command lands ONE binary. Nothing else exists. TIER 0 host
│
│ it reads a pinned file it already carries and raises a
│ database, a bus, an object store and an image
│ registry — locally, alone. TIER 1 substrate
│
│ on those, the mesh's brain starts: which nodes exist,
│ what runs where, what is reachable. TIER 2 control plane
│
│ ways to talk to that brain. TIER 3 surfaces
│
└ everything the mesh then carries. TIER 4 workloads
```
**Dependencies point only downward.** The substrate never references the control plane. That
one constraint is the entire bootstrap answer, because it guarantees there is always a place to
start.
Why it earns its keep here: **the current mesh violates this, and that is the circularity that
keeps recurring.** The mesh database is a module; modules are installed by the delivery
pipeline; the pipeline needs the database. No order works, so a first-node script exists to
paper over it, and every later substrate change has to pretend the problem is not there.
A tier is not a repository and not a bounded context. Those are different cuts: a context says
*who owns this concept*, a tier says *what must already be running*.
## The tree
```
mesh-host/ TIER 0 — the only thing ever installed by hand
apply/ reconcile declared state on this machine
inventory/ what this node is, has, and is capable of
link/ the single outbound connection to the control plane
store/ embedded local state — authoritative while disconnected
profile/ capability detection: managed · user · edge
substrate.lock pinned tier-1 descriptor, appliable with no mesh present
mesh-substrate/ TIER 1 — declarations only, no logic of its own
store/ relational state
bus/ commands and events
objects/ blobs and build artifacts
images/ container images
bundle.yml the pinned set tier 0 can raise alone
mesh-control/ TIER 2 — the control plane
record/ the event log every context integrates through
inventory/ nodes · modules · assignments · versions
config/ settings · secrets · derivation onto nodes
connectivity/ overlay · resolution · exposure · filtering · certificates
provisioning/ resource grants between modules
delivery/ source → artifact → node
observability/ health · logs · metrics · alerts
identity/ agents · humans · services · authorisation
work/ tasks · workflows · runs
knowledge/ memory · documents · retrieval
api/ the one interface every surface speaks to
mesh-surfaces/ TIER 3 — thin; no logic lives here
tools/ the agent-facing tool surface
web/ the operator-facing interface
cli/ the shell-facing interface
mesh-catalog/ TIER 4 — what the mesh hosts
<domain>/ grouped per ADR 0022, list per research 005
mesh-lab/ the whole mesh, disposable, on one machine
mesh-sdk/ contracts shared across tiers — types, not behaviour
hq/ company-scoped, not a mesh repository — ADR 0019
```
## The dependency rule
**A tier may depend only on tiers below it.** Substrate never references the control plane.
The control plane never reaches into a node except through the host. A surface holds no logic
a second surface would have to reimplement.
This is the whole of the bootstrap answer, and per this repository's own rule it must say how
it is checked: a dependency-direction lint in the build, failing on an upward import. A tier
rule enforced by intention is the same as no tier rule — that is
[ADR 0010](../../02-DECISIONS/0010-delivery.md) applied to architecture.
## Move 1 — the substrate is applied, not delivered
**The problem.** The mesh needs a database, a bus, an object store and an image registry.
Today those are modules, and modules are installed by the delivery pipeline, which
needs the database and the bus. The first node is therefore raised by a special script that
exists only because of the circularity, and every later change to the substrate has to pretend
the circularity is not there.
**The move.** The host can apply a declaration without anyone telling it to. The substrate is
a **pinned bundle** the host carries: a fixed, versioned, self-contained descriptor of the
four services and nothing else. Raising a first node is `host apply substrate.lock` — not a
special path, just the ordinary one with no control plane on the other end.
The circularity disappears rather than being worked around: **the substrate is applied by tier
0, the mesh is delivered by tier 2, and they are different mechanisms on purpose.**
The price is real and should be named: the substrate is upgraded by bumping a pin and
re-applying, not by the pipeline. It gets less machinery than everything else — no per-node
selection, no provisioning, no fan-out — and that is the point. Five services justify a
simpler mechanism than a hundred.
## Move 2 — the host is one binary with capability profiles
**The problem.** Everything assumes root on a machine whose packages, services and network the
mesh owns. A phone cannot offer that, and neither can a work laptop. The current answer would
be a lightweight fork, which means two implementations and one of them rotting.
**The move.** One host binary, and a **profile** it detects rather than is told:
| Profile | Can | Typical |
|---|---|---|
| `managed` | packages, services, network, filesystem — the full surface | a machine the mesh owns |
| `user` | user-level services and tools; no package or network management | a shared or administered machine |
| `edge` | report presence, relay, expose a tool surface; hold nothing | a phone |
A module declares which profiles it can land on. Assignment to an incapable node fails at
declaration time, not at deploy time — a phone is not a machine that fails to install a
firewall; it is a node the firewall module cannot be assigned to.
This makes the phone case a **capability question rather than a platform question**, which is
what keeps it from becoming a second implementation. It also removes the current unstated
assumption that every node is equivalent — already false, and today handled by remembering.
## Move 3 — connectivity becomes a context
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) names nine contexts
and none of them owns the overlay, the resolver, the firewall or the ingress. `config` owns
PKI, which is the closest thing, and it is not close.
Meanwhile [research 005](../005-domain-grouping/analysis.md) measured the whole catalogue and
found that reachability is the **only** place where modules genuinely change together under one
intent — the proxy with the resolver, the firewall with the overlay, repeatedly, because *how a
node is reachable* is one question asked in four places.
So the evidence and the gap point the same way. `connectivity` owns:
- the overlay every node joins, and the addresses on it
- name resolution, internal and public
- exposure — which services answer from outside, on which names
- filtering — what may reach a node at all
- certificates for both name spaces
This is an addition to an accepted record, so it is a decision, not a drafting choice. It
belongs in a new record that extends ADR 0001 the way
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) does —
not written here.
### Where the networking actually lives
A context is an authority, not a running thing, so naming one does not say who brings the
overlay up. That splits three ways, and the split is the design:
| Concern | Tier | Why there |
|---|---|---|
| **Overlay membership** — this node joins, holds an address, keeps the tunnel up | **0, in the host** | Everything cross-node needs it *before* the substrate is reachable from elsewhere. A module cannot provide it, because installing a module is itself a cross-node operation. |
| **Policy** — who holds which address, what resolves, what is exposed, what is filtered | **2, `connectivity`** | Bookkeeping and authority. It decides; it runs nothing. |
| **Machinery** — resolver, reverse proxy, firewall backend, certificate issuance | **4, modules** | Swappable, and not every node needs them. A node without a reverse proxy is still a node. |
There is a second circularity hiding here, and it has to be closed explicitly: **the host's link
to the control plane does not run over the overlay.** If it did, the overlay would have to be up
before the host could be told how to join it. The link is ordinary outbound internet to a public
endpoint; the overlay carries node-to-node traffic only.
Joining is therefore: host lands → links out with a join token → control plane returns an
address and keys → host raises membership → the substrate on other nodes becomes reachable.
This also makes the `edge` profile honest rather than special-cased. A phone can hold overlay
membership in userspace without privilege, and cannot run the machinery. That is the profile
distinction doing its job.
## Move 4 — `feature` splits in two
The invitation was to check whether the concept survives. It does not, in one piece.
Today a **feature** means both *a thing built once* and *a thing selected per node*, and the
delivery pipeline is hard to reason about precisely because those have different cardinality
and one word ([ADR 0010](../../02-DECISIONS/0010-delivery.md)
is the pipeline half of the same confusion).
Split it:
| Concept | Is | Cardinality |
|---|---|---|
| **artifact** | something built and published — an image, a bundle, a package | once per module version |
| **part** | an independently selectable piece of a module's desired state | chosen per node, per assignment |
A module declares desired state in parts, and produces artifacts. An assignment names the node
and the parts. Delivery builds artifacts once and applies parts per node — and the two words
now carry the two cardinalities that the pipeline already has.
This keeps what features are genuinely for — per-node opt-in of *some* of a module, which the
work breakdown already calls for — and drops the conflation that makes the current model
confusing.
## What the mesh is, versus what it hosts
Tiers 0–3 are the mesh. Tier 4 is everything it carries, and the boundary is stated by
requirement rather than by taste: **a module is part of the mesh if removing it stops the mesh
managing nodes.** A media server does not. A relational store does — which is why it sits in the
substrate and not the catalogue, despite being, in every other respect, an application like any
other. An identity provider, notably, does **not**: the control plane authenticates its own
callers, so identity is a hosted service like the media server.
That test also settles the IT-company goal without a special category. Development, design and
deployment tooling are **workloads** — tier 4, hosted, provisioned, delivered like anything
else. The mesh does not grow a "company" feature; it hosts the tools a company runs on, and
the fact that it runs its own development on them is dogfooding, not architecture.
## What agents are, structurally
Self-improvement and self-healing are not a tier. Agents are participants
([ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)) that hold identity in
tier 2, act through tier 3 like any other caller, and run as workloads in tier 4.
This matters for one reason: **an agent must not have a privileged path**. Anything an agent
can do to the mesh, a person can do through the same surface, and anything it cannot express
through the tool surface is a gap in the surface rather than a reason for a back door. Self-
healing built on a private channel is unreviewable, and would be the one part of the mesh with
no human checkpoint.
## How this is tested
The lab ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) raises the
tree above on one machine: virtual machines as nodes, a real overlay between them, the real
substrate bundle, the real control plane, the real delivery path.
The tier rule is what makes that affordable. A scenario needing only tiers 0 and 1 is one
virtual machine and a pinned bundle — which is also, exactly, the bootstrap path. **The
hardest thing to test becomes the cheapest scenario to run**, and the first-node path stops
being the one thing nobody exercises until it breaks.
## What this skeleton does not answer
- Where the record lives. It is infrastructure by shape and domain by content, and putting it
in the substrate risks recreating a circularity in the one place the design just removed one.
- Whether tier 2's contexts are one repository or several. Open from ADR 0001 already.
- Whether an `edge` node is in the inventory or merely present — which decides whether "node"
is one concept or two.
- The migration. Nothing here says how today's mesh becomes this, and the skeleton is worth
little until that is costed.
## A naming near-miss, recorded
The tier-0 binary was first called `mesh-agent`, because "node agent" is the reflex everywhere
else in the industry. That is wrong here, and wrong in the specific way
[`how-we-build.md`](../../00-META/how-we-build.md) §4 exists to catch: **Agent** is a
first-class concept in this mesh — a participant, some of whom are human, holding identity and
memory ([ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)). One document
carried both meanings.
It is the same failure as the anatomy naming in the current runtime: an evocative domain word
pointing at infrastructure.
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) supplies the fix in
its own title — *the mesh brokers capabilities; nodes host; agents think.* Three verbs, three
components: the control plane **brokers** (`mesh-control`), the tier-0 binary **hosts**
(`mesh-host`), the participant **thinks** (`agents`, untouched).
`mesh-node` was the alternative and was rejected: *Node* is the aggregate in the inventory — the
record of a machine — while the binary is what runs on it and does the hosting.
@@ -0,0 +1,51 @@
---
status: active
initiated: 2026-08-23
touches:
- 03-DESIGN/00-as-is/03-provisioning.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
became: []
---
# 007 — Provisioning as the mesh's core mechanism
## What is being investigated
Provisioning is the mechanism the whole mesh rests on: a module declares what it needs, and the
mesh makes it exist, generates the credential, records the grant, and puts the values where the
module will read them. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)
calls it the mesh's core concern rather than its plumbing.
[Research 006](../006-mesh-from-scratch/code-skeleton.md) then asks it to carry **more**: the
control plane becomes a consumer with its own requirements — a source of record, an image
registry, a package registry — satisfied by the same mechanism. That generalisation is only
safe if the mechanism is sound, and the as-is record says it is not, in named ways.
## Why now
Four weaknesses are already documented in
[`03-DESIGN/00-as-is/03-provisioning.md`](../../03-DESIGN/00-as-is/03-provisioning.md), each
observed rather than theorised:
- **Rotation has no fan-out.** A shared credential can be rotated without telling the peers
holding the old one. This has locked the mesh out of its own broker.
- **A grant is not a check.** The record says a resource was provisioned. Nothing verifies it
still exists, still has that credential, or is reachable from where the consumer runs.
- **A frozen password outlives its generation.** A generated secret written once diverges from a
persistent data directory initialised earlier, and presents as an authentication error.
- **No requirements is indistinguishable from provisioning that did not run.** A module that
declares nothing skips the stage, which is correct, and looks identical to failure.
Generalising a mechanism with these properties to the control plane's own dependencies would
make each of them fatal rather than annoying.
## The questions
| Question | Why it matters |
|---|---|
| What does a **grant** mean, exactly — a record that a resource was created, or a claim about the world that is continuously reconciled? | The difference between the current model and one where "provisioned" is checkable. Almost every weakness above is a symptom of the first answer. |
| How is a credential **rotated** with fan-out to every holder? | The mechanism grants easily and regrants not at all. This is the most damaging gap and it has taken the mesh down. |
| Can the **control plane** hold requirements, and what satisfies them before anything is installed? | The generalisation research 006 needs. Ties directly to the self-hosting transition. |
| What happens when a requirement **cannot** be satisfied — no provider, provider on an unreachable node, provider not yet installed? | Today this is silent or a stall. It should be a stated, visible state. |
| Does a requirement belong to a **module** or to one of its **parts**? | Research 006 splits `feature` into artifact and part. A part-scoped requirement means a database is not provisioned where the part that needs it is not installed. |
@@ -0,0 +1,101 @@
---
status: graduated
initiated: 2026-08-23
touches:
- 03-DESIGN/00-as-is/04-delivery.md
- 02-DECISIONS/0010-delivery.md
- 02-DECISIONS/0010-delivery.md
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
became:
- 02-DECISIONS/0010-delivery.md
- 02-DECISIONS/0010-delivery.md
---
# 008 — The coordinator: a change checked in becomes a deployed state
## What is being investigated
The mesh's own continuous delivery: a change is committed, and the mesh ends up in the state
that change describes — across every node the change touches, with a verdict that says whether
it worked.
The coordinator is what orchestrates that, and it is the mesh's most consequential machinery:
everything reaches every node through it.
## Why now
The as-is record ([`03-DESIGN/00-as-is/04-delivery.md`](../../03-DESIGN/00-as-is/04-delivery.md))
names problems that are structural rather than incidental:
- **A green pipeline proves transport, not effect.** The stages report that a message was
dispatched and accepted, which is not the same as the thing running, correct, or present.
This is the mesh's single most consistent failure shape.
- **Detection is the most fragile input.** A merge that creates no pipeline, with nothing saying
so, is the characteristic bad outcome — and it has happened for reasons unrelated to the
change.
- **The fan-out point is asymmetric.** The build node has already passed two silos when work
fans out, and code that knew only about the first parked it forever while every other node
deployed cleanly.
- **There is no end-to-end coverage.** The harness has not built since 2026-06-04
([`04-ISSUES/005`](../../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md)).
Research 006 adds a requirement the current design does not have: the coordinator must work
**before the mesh is self-hosting**, when source and artifacts come from outside, and keep
working across the transition to self-hosted providers.
## What it became
*Closed 2026-08-28.* All six questions are answered, by two records, and the second exists
because the first was honest about what it did not fix.
**Does the coordinator dispatch stages, or converge nodes on a declaration?** — *Converge.*
[ADR 0010](../../02-DECISIONS/0010-delivery.md): a pipeline ends when the
declaration is updated, and the host applies it and reads back — so the reporter is the applier.
**Does the three-silo split survive?** — *Yes, with the third redefined.* The cardinality
observation holds; the third silo is not a stage any more.
**How does a change become a pipeline, reliably?** — *It does not become a pipeline at all.*
[ADR 0010](../../02-DECISIONS/0010-delivery.md) applies 0058's
move one level up: the control plane holds what source exists and what has been built, and builds
the difference. **An event makes it fast; nothing makes it necessary.** The failures this effort
catalogued — a truncated commit list, a broken path match — become latency rather than silence.
**What is a deployed state?** — *Two comparisons, not an event.* Does every node's reported state
match what is declared, and is what is declared built from current source? A milestone can be
claimed by something that did not check; a comparison cannot.
**What produces a verdict, and what is it about?** — *An artifact, and it gates eligibility.* The
mesh must not converge onto something broken, so an artifact may be declared only once the lab
has judged it fit. Sharper than the question expected: a verdict is a property an artifact has,
not a report about a run.
**How does delivery work before self-hosting?** — *It mostly stops being a question.* A reconciler
reads source and writes artifacts; where those live is a binding, external first and internal
later. The transition looked hard because a pipeline's stages name their targets.
## What this effort was right about
Its first question — *what is a deployed state, and how does the mesh know it is in one* — was
marked "everything follows from this", and everything did. Both records above are answers to it:
0058 makes the applier the reporter, and 0063 makes currency a comparison. The effort put the
load-bearing question first.
## What is NOT closed by this
[ADR 0010](../../02-DECISIONS/0010-delivery.md) names four costs
and one of them is a real risk rather than a trade: **a reconciler that cannot reach its target
retries forever, and without something that notices, the failure is silence** — which is the
fault this effort exists to catalogue, reintroduced in a new place. That belongs to observability
and it is not designed.
## The questions (all answered above)
| Question | Why it matters |
|---|---|
| What is a **deployed state**, and how does the mesh know it is in one? | Everything follows from this. If a stage reports transport, "deployed" is a claim nobody checked. A desired-state model with reconciliation gives a different answer from a job-completion model. |
| Does the coordinator dispatch **stages**, or converge nodes on a **declaration**? | The current model is a state machine over stages. The alternative is that a node is told what should be true and reports what is. The second makes drift visible; the first cannot see it. |
| How does a change **become** a pipeline, reliably? | Detection has failed for reasons unrelated to the change, silently. |
| What produces a **verdict**, and what is it a verdict about? | Ties to the lab ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) and to a module carrying its own assertions. |
| How does delivery work **before self-hosting**, and across the transition? | From research 006: source and artifacts start external and are re-bound to internal providers. The coordinator has to be indifferent to which. |
| Does the **three-silo** split survive the artifact/part split? | [ADR 0010](../../02-DECISIONS/0010-delivery.md) is cardinality-driven, and research 006 renames the thing the cardinality is about. |
+95
View File
@@ -0,0 +1,95 @@
---
status: active
initiated: 2026-08-23
touches:
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
- 03-DESIGN/01-to-be/01-end-to-end-testing.md
- 02-DECISIONS/0016-the-lab.md
- 03-DESIGN/00-as-is/00-overview.md
became: []
---
# 009 — Getting from the mesh that exists to the mesh that is designed
## What is being investigated
How a running mesh becomes the one in
[research 006](../006-mesh-from-scratch/code-skeleton.md), without losing what it currently
carries.
The proposed shape: **build tiers 0, 1 and 2, then replace the current setup in one move.**
## Why big-bang is the right instinct here
Recorded because incremental is the reflex answer and it is wrong in this case.
- **The two models are structurally incompatible.** The tier rule, the host absorbing what are
now modules, the artifact/part split, provisioning generalised to the control plane's own
requirements — none of these can half-apply. Running both models at once means the old one's
assumptions keep constraining the new one, which is how a migration becomes permanent.
- **Nothing external depends on it.** No users outside the operator, no service level to hold.
- **The lab exists precisely for this** ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)).
A big-bang that has been rehearsed end to end, repeatedly, on identical machines is not the
same risk as one performed for the first time on the real mesh. This is also why the lab is
phase 0 rather than a verification step later: the new mesh is *developed* inside it, so by
cutover the procedure has been run hundreds of times rather than rehearsed a few.
- **Incremental would carry the rot forward.** The as-is layer documents silent failure paths,
a dead test harness and unenforced rules. A gradual migration preserves them by definition.
## The distinction that lowers the risk
**Replace the control plane; do not move the workloads.**
The things that would hurt to lose — mail, media, source, databases, their data directories —
are not the mesh. They are what the mesh manages. They sit in container volumes on nodes, and
they do not need to move for the control plane above them to be replaced.
So the big-bang is: the old control plane stops managing these nodes, and the new one starts —
with the workload data untouched, in place, and re-declared rather than migrated.
That reframing turns "replace the mesh" into "replace the part with no persistent state of its
own", which is a materially smaller act than it first sounds.
## The tension this exposes
Taking over already-running workloads is **adoption**, and adoption was ruled out of scope —
recorded as a legacy path in
[`03-DESIGN/01-to-be/01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md).
The migration appears to need exactly the capability the design declared it would not have.
The way out, to be tested: the new mesh does not adopt anything. It **declares** the workloads
from scratch and points them at data directories that already exist. Nothing inspects a running
machine to learn what is there; the declarations are written from the as-is layer, which is what
that layer is for. Data survives because it was never touched, not because it was adopted.
If that holds, adoption stays out of scope and the migration is ordinary declaration. If it does
not, adoption needs a one-time, explicitly unsupported tool, and that should be a decision rather
than a discovery.
## Sequencing
| Phase | What | Done when |
|---|---|---|
| **0** | **Build the lab's bootstrap scenario** ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) — virtual machines, a network, a way to place a binary, snapshot and reset. No forge, no coordinator, no pipeline. | A machine can be raised from nothing, reset, and raised again, repeatably. |
| A | Build tier 0, **inside the lab**. The host's interface first — it carries the skeleton's biggest unproven claim. | A bare machine becomes a managed node with no mesh present. |
| B | Build tier 1 and 2. The bootstrap scenario grows into the full one by addition — the same machines, with more placed inside them. | The lab raises a full mesh from nothing, repeatedly, from pinned external artifacts. |
| C | Enough of tier 3 to operate it. | The mesh can be driven without direct database access. |
| D | Declare the existing workloads against the new model. | The lab runs them, with copies of real data shapes. |
| E | **Rehearse the cutover in the lab** against a mesh built to resemble the real one. | Repeatable, and repeatably reversible. |
| F | Cut over. | The real nodes are managed by the new control plane. |
| G | Reach self-hosting — re-bind delivery from external providers to the mesh's own forge and registries. | The mesh builds and deploys itself. |
Phase G is deliberately last. Per research 006, self-hosting is a state the mesh **reaches**;
attempting the cutover and the self-hosting transition in the same move recreates exactly the
circularity the skeleton removes — and would mean a failed cutover could take away the means to
fix it.
## The questions
| Question | Why it matters |
|---|---|
| What state must **survive** the cutover, versus be re-created? | Workload data must. Provisioned credentials could be re-issued. The mesh's own inventory could be re-declared. Each answer changes the risk. |
| What is the **way back**? | A cutover with no rollback is not a plan. If workload data is untouched, reverting may be as small as re-pointing the old control plane at it — to be verified, not assumed. |
| How is the cutover **rehearsed** against something resembling the real mesh, without copying the real mesh into a repository? | The lab must be able to model the real topology's shape without carrying its identity. |
| Does anything have to keep running **during** the cutover? | Mail and source are the obvious candidates. If yes, "big-bang" is really "big-bang with exceptions", and the exceptions should be named now. |
| Is the forge inside or outside the cutover? | If the mesh's own forge goes down with the old control plane, the means of deploying a fix goes with it. This is the self-hosting circularity appearing as a migration risk. |
@@ -0,0 +1,60 @@
---
status: active
initiated: 2026-08-24
touches:
- 03-DESIGN/01-to-be/03-scenario-lifecycle.md
- 02-DECISIONS/0016-the-lab.md
- 02-DECISIONS/0016-the-lab.md
became: []
---
# 010 — What the lab's inner loop actually costs
## What is being investigated
[The lifecycle design](../../03-DESIGN/01-to-be/03-scenario-lifecycle.md) closes on an open
question that is measurable rather than arguable:
> **What a snapshot costs.** Whole-scenario snapshots of several machines are the operation the
> inner loop repeats most, so their cost sets the loop's speed. If restoring is slow, the loop
> is slow, and everything above is theory.
Measured on a workstation, 2026-08-24. Numbers in [`measurements.md`](measurements.md).
## Why it matters
[ADR 0016](../../02-DECISIONS/0016-the-lab.md) makes the
bootstrap scenario the inner development loop for tiers 0 and 1 — the argument being that
raising a node from nothing stops being the least-exercised path and becomes the most-exercised
one. **That argument is only true if raising and resetting are cheap.** A loop that costs
minutes is a loop people avoid, and the least-exercised path stays least-exercised.
## Status
Measured, and the finding is a blocker rather than a data point.
**A snapshot on the current host is a full copy of the machine's disk.** 1.6 GB and ten seconds
for one small virtual machine at best — and over two minutes when observed a second time. Cost
scales with the number of machines and the size of their disks, not with what changed.
**With copy-on-write it is 0.13 seconds and costs the delta.** Verified, not assumed. The
projected four-machine reset cycle falls from roughly ninety seconds to roughly fifteen, of
which almost all is a boot that cannot be avoided.
The cause is not virtual machines and not incus. It is that the host offers incus exactly one
storage driver, `dir`, which has no copy-on-write and therefore no cheap snapshot. The kernel
supports btrfs; the userspace tool that would let incus use it is simply not installed.
So the question *"is the lab's inner loop fast enough"* currently answers itself the wrong way,
for a reason that is one declared package away from being fixed — and declaring packages is
something the mesh already does.
## Open questions
| Question | Why it matters |
|---|---|
| ~~How much does a copy-on-write pool actually improve it?~~ **Measured: snapshot 9.9 s → 0.13 s, restore 10.4 s → 0.80 s, three snapshots sharing 1.36 GB rather than costing 4.8 GB.** The projected four-machine cycle falls from ~90 s to ~15 s. | Answered. The inner-loop argument holds *with* copy-on-write and did not without it. |
| How does the lab install its own prerequisites on a clean machine? | The lab needs a virtualisation daemon, copy-on-write tooling and a pool before it can do anything — and it cannot depend on the mesh for them, since it is where the mesh is built. |
| Why was the second snapshot more than twelve times slower than the first? | If snapshot cost is unpredictable rather than merely high, that is worse — a loop with a variable multi-minute step is one nobody trusts. |
| Does a scenario snapshot need the machines stopped? | Stateless snapshots of a running virtual machine capture the disk but not memory. Whether a mesh restored that way is coherent is not established. |
| What is the cost at scenario scale — four machines rather than one? | Only single-machine numbers were taken. If the operation is serial, four machines is four times the wait. |
@@ -0,0 +1,141 @@
---
effort: 010-lab-inner-loop-cost
updated: 2026-08-24
---
# Measurements
Taken 2026-08-24 on a workstation with hardware virtualisation available, an NVMe-backed ext4
root, and 300 GB free. One virtual machine, 1 GiB memory, 2 CPUs, from a cached distribution
image.
## The environment, before anything ran
| Fact | Value | Consequence |
|---|---|---|
| Hardware virtualisation | present | virtual machines run at native speed; the choice in [ADR 0016](../../02-DECISIONS/0016-the-lab.md) is not paying an emulation penalty |
| Storage drivers the daemon offers | **`dir` only** | no copy-on-write, therefore no cheap snapshot |
| Host filesystems | ext4 throughout | nothing copy-on-write to put a pool on |
| btrfs kernel module | **available** | the kernel can do it |
| `btrfs-progs` | **not installed** | which is the entire reason the driver is absent |
The last two rows are the finding. The daemon advertises only `dir` because the userspace tool
for anything better is missing — not because the host cannot do better.
## Raising a machine
| Step | Time |
|---|---|
| launch call returns | **3.4 s** |
| machine actually usable — a command executes on it | **14.3 s** |
The gap matters for the lifecycle design: `raise` returning is not the same as the scenario
being ready, so the verb has to wait for the second number, not report the first. Reporting
the first would be the mesh's own recurring failure — transport reported as effect.
## Snapshot and restore
| Operation | Time | Disk |
|---|---|---|
| snapshot, first | **9.9 s** | **+1.6 GB** |
| snapshot, second | **> 120 s — did not complete** | — |
| restore call returns | **10.4 s** | — |
| machine usable again | **20.1 s** total | — |
Instance on disk before snapshotting: 1.5 GB. Snapshot directory afterwards: 1.6 GB. **A `dir`
snapshot is a full copy** — the storage cost equals the instance, and nothing is shared.
Implied copy throughput on the first snapshot is roughly 160 MB/s, which is far below what the
underlying NVMe can do and is consistent with a real, durable copy rather than a metadata
operation.
**The second snapshot is the more troubling number.** It exceeded two minutes and was still
running when the observation was cut off; only the first snapshot exists. Whatever the cause —
page cache exhausted by the preceding restore, writeback contention — the practical
consequence is that snapshot cost here is **not merely high, it is unpredictable**.
## What this projects to
A four-machine scenario, taking the optimistic single-machine numbers and assuming the
operations are serial:
| | one machine | four machines |
|---|---|---|
| raise, to usable | 14 s | ~57 s |
| snapshot | 10 s, 1.6 GB | ~40 s, 6.4 GB |
| restore, to usable | 20 s | ~80 s |
A reset-and-rerun cycle is therefore **around a minute and a half at best**, and unbounded at
worst, before any of the mesh's own work begins.
## The judgement
**This is too slow for an inner loop**, and the reason is not the design.
[ADR 0016](../../02-DECISIONS/0016-the-lab.md) argues that
making the bootstrap path the inner development loop turns the least-exercised code in the
system into the most-exercised. That argument holds only while resetting is cheap. At a minute
and a half a cycle, with occasional multi-minute stalls, the loop is one a person works around
— and the path stays under-exercised for exactly the reason it always was.
Nothing about virtual machines causes this. Hardware virtualisation is present and the machines
boot in fourteen seconds. **The cost is entirely the storage driver**, and the driver is absent
because one userspace package is not installed on the host.
The mesh already has the mechanism for that: a module declares a package, and a hook makes it a
working capability — which is precisely what was just done for the virtualisation daemon
itself, and what [`04-ISSUES/007`](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)
is about.
## The fix, measured
The comparison was subsequently run. One package — `btrfs-progs`, no dependencies — installed by
hand, the daemon restarted so it re-detected drivers, a copy-on-write pool created on a loop
file, and the identical image launched onto it.
| Operation | `dir` | copy-on-write | |
|---|---|---|---|
| snapshot | 9.9 s, then **> 120 s** | **0.13 s** | ~76× faster, and *consistent* |
| snapshot again | — | 0.12 s | |
| snapshot a third time | — | 0.13 s | |
| restore call | 10.4 s | **0.80 s** | ~13× faster |
| restore, to usable | 20.1 s | **10.5 s** | the remainder is boot, which is irreducible |
| three snapshots, storage | ~4.8 GB | **1.36 GB total, shared** | cost is the delta, not the disk |
**The fix is real, and larger than expected.** Snapshot goes from ten seconds to a tenth of a
second, and — more importantly — from *wildly variable* to *flat*. Three consecutive snapshots
took 0.13, 0.12 and 0.13 seconds. On `dir` the second snapshot never finished.
Storage stops scaling with the machine and starts scaling with what changed: three snapshots of
a 1.5 GB instance occupied 1.36 GB in total, because they share.
### What it projects to
A four-machine reset-and-rerun cycle, the operation the inner loop repeats most:
| | `dir` | copy-on-write |
|---|---|---|
| snapshot the scenario | ~40 s, 6.4 GB | **~0.5 s**, delta-sized |
| restore it | ~40 s + boot | **~3 s** + boot |
| **cycle** | **~90 s, unbounded at worst** | **~15 s, dominated by boot** |
At fifteen seconds, dominated by a boot that cannot be avoided, the inner loop is viable and
[ADR 0016](../../02-DECISIONS/0016-the-lab.md)'s argument holds.
At ninety it did not.
### One honest counter-observation
Launching onto the fresh copy-on-write pool was **slower** — 20.2 s to usable against 14.3 s —
because the image had to be unpacked into a pool that had never seen it. That cost is paid once
per pool, not per scenario, and it is dwarfed by what snapshotting saves. But it is a real
number and it went the other way.
### State this left behind
Recorded because hand-made state is exactly what the mesh's rules exist to prevent, and it must
be declared properly rather than left as an artefact of a measurement:
- `btrfs-progs` installed by hand. Its installation regenerated the boot initramfs, a side
effect worth knowing about.
- The daemon restarted once, to re-detect drivers.
- The test pool and instance were **removed**; the pool the lab actually needs does not exist.
@@ -0,0 +1,203 @@
---
status: graduated
initiated: 2026-08-25
became:
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0008-a-context-owns-its-store.md
- 03-DESIGN/01-to-be/06-the-controller.md
- 03-DESIGN/01-to-be/07-the-foundation.md
touches:
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0004-a-node-and-how-it-joins.md
- 03-DESIGN/00-as-is/02-modules-and-manifests.md
- 03-DESIGN/00-as-is/10-module-catalogue.md
- 04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md
---
# 011 — The module graph
> **A third edge was found after this graduated.** This effort established *presence* and
> *instantiation*, and both are **runtime** edges — they answer *what does this need in order to
> run*. Delivery needs a different question answered — *what has to be rebuilt when this changes*
> — and that is a **build** edge, fixed inside an artifact rather than negotiated when it runs.
> Recorded by [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md), which also
> notes what this effort's three entities turn out to be good for
> ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
## What is being investigated
Whether the catalogue's missing structure is a **graph** — modules declaring what they need,
what they offer, and what they exclude — and what that replaces.
**[`worked-provider.md`](worked-provider.md) works one module through completely**, and breaks
the tidy version. A database is nine things, not one — and a small game asking the mesh for its
own database shows there are **two kinds of edge**: *presence*, where the thing must exist, and
*instantiation*, where a provider makes something for a consumer and hands back credentials.
Instantiation implies presence and not the reverse. The current system already had this split
and the design had collapsed it.
**[`features.md`](features.md) answers what happens to `feature`.** It is one word for four
things spanning three tiers — artifacts built once per version, resources applied to a machine,
actions run against something that is not this machine, and checks that are requirements in
disguise. Measured: every one of the twenty-one handlers implements all six stages, so `configs`
has a build stage with nothing to build and `npm` has a start stage with nothing to start. That
emptiness is the conflation, and it is why a stage that did nothing and a stage that failed look
alike. Nothing replaces it, because it was never one concept. **One property is worth keeping:
content is detected, relationships are declared.**
**[`cases.md`](cases.md) enumerates what a module can be** — twenty kinds of thing the mesh has
to install, run, own or know about — and extracts the axes a manifest must express. Two of those
axes appear in no current thinking: **how many instances** a thing may have, and **whether two
can coexist**.
**The design is in [`proposal.md`](proposal.md): one kind of edge.** A module provides names
and requires names, and that single relation absorbs requiring a module, requiring a resource,
and the interface-and-adapter idea. An abstract name is legitimate **only where providers are
genuinely substitutable** — `terminal` passes, `database` does not, because a consumer speaking
Postgres does not speak MongoDB. The adapter is what creates an interface; without one there is
a **tag**, which describes and does not bind.
A **node provides names too**, which makes capability checking stop being a separate mechanism
and makes the host's own capability report an input to resolution rather than something a
person reads.
[`analysis.md`](analysis.md) measured the current catalogue. Its value to the design is two
lessons rather than its machinery — a field that means *depends on* should say so, and
placement does not belong in a manifest — and the rest is recorded as as-is evidence.
**Measured, and the premise was wrong for the existing system: the graph is not missing there.**
[`analysis.md`](analysis.md) — 126 manifests, 103 edges, no cycles, nothing dangling, and a
resolver that topologically sorts them, already called by the tool loader, the installer and
the delivery coordinator. What the effort assumed would need building is a thing to call.
What survives is narrower: three declarations that do not exist (`excludes`, a required node
capability, an interface with adapters), and two defects worth fixing whatever else is
concluded — `provider:` is a dependency edge that is not read as one, which makes the closure
for a working mesh come out without a database; and the resolver continues past a cycle and
past a missing dependency, contrary to ADR 0001.
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) proposes
grouping modules by domain. [Research 005](../005-domain-grouping/analysis.md) measured that
proposal and found its evidence holds in exactly one place — reachability — which
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) has since absorbed
into the host. The measured case for domain grouping has therefore been consumed by a decision
taken for unrelated reasons, and what remains is fifty modules that co-change with nothing.
That leaves the original complaint unanswered: the catalogue records **what was installed**
rather than **what anything is for**, and nothing in the system can see a relationship between
two modules. Grouping asserts relationships. A graph records them.
## Why now
A proposal to split modules into *provisioning services* and *applications* was worked through
and abandoned in favour of one concept with facets, for a reason worth keeping:
- The split cannot be filed consistently. A git forge is consumed as a service *and* operated
through a web interface. An analytics service grants tracking identity *and* is a dashboard
somebody reads. An identity provider grants authentication *and* has an admin console.
- The operator's correction is the sharper form: **what runs on the machine is a supervised
container, not something a user started.** That is a fact about *how a thing runs*, not about
what kind of thing it is — so it is a facet, not a taxonomy.
Filing decisions that follow from nothing are the disease research 005 measured. A second
taxonomy would reproduce it.
So [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) survives, and the question
becomes what a module must be able to **declare**.
## The shape being investigated
Five declarations, of which two exist today.
| Declaration | Today | Notes |
|---|---|---|
| **requires a resource** — a database, a bucket | yes | provisioning, [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
| **provides a resource** | yes | as above |
| **requires another module** | **no** | the dependency edge — the graph's substance |
| **excludes another module** | **no** | installing A makes B unavailable |
| **requires a node capability** | **no** | a graphical session, a container runtime, an architecture |
And one structural idea on top: **an interface module carries no implementation.** Adapters
provide it. An assistant interface with several model-provider adapters; a terminal interface
with several terminal adapters. A dependent names the interface and never an implementation.
**Prior art to measure against, not invent past.** This is a package manager's model, and the
platform's own package manager already has all of it: `depends` is the dependency edge,
`conflicts` is exclusion, and `provides` is the interface — several packages provide one
virtual name, and a dependent names the virtual one. That the design arrived at the same shape
independently is evidence for it. It is also a warning: dependency resolution, version
constraints, conflict handling and rollback are a long-solved and easily-botched problem, and
the effort should establish what to **delegate** rather than reimplement.
## Capabilities, and what may be installed
The operator's formulation: *system specs are capabilities, and capabilities unlock installable
modules — you cannot install a graphical application on a node with no display server.*
The question that follows is whether the mesh may install a capability. The effort's working
position, to be tested:
- **Intrinsic capabilities** — hardware, architecture, network position — are facts about a
machine. They are detected, never installed, and a module requiring one it does not have is
not unresolved but **impossible** on that node.
- **Provided capabilities** — a display server, a container runtime — are not a separate kind
of thing at all. They are modules that provide a capability, and requiring one is an ordinary
edge the graph resolves by installing it.
If that holds, "may the mesh install a capability" is not a policy question. It is dependency
resolution, and the only genuinely new thing is detection.
Which is where [issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)
bears directly: *an installed package is not a capability*. A provided capability is not real
because a package is present — it is real when it is present, running and working, and the
difference is exactly the class of fault this repository keeps recording.
## What it touches beyond the catalogue
The operator's assessment is that the machinery around a module is wanted and its integration
is not: **scheduled tasks, hooks, migrations, configuration and environment settings are worth
keeping; seeds are not; and the current integration is wrong enough to need a major refactor.**
That is a claim to test rather than adopt. Research 005 already found supporting evidence from
a different direction — that the densest apparent coupling in the catalogue is manifest
boilerplate churn, cross-cutting changes to the machinery applied N times — which is what an
integration being wrong looks like from the outside.
## Open questions
Struck-through rows are answered, with where. The rest are live.
### Settled
| Question | Answer |
|---|---|
| ~~What does the graph **delete**?~~ | For the existing system: nothing, it is already there ([`analysis.md`](analysis.md)). For the design: the module/resource distinction, the interface as a kind of thing, capability checking as a separate mechanism, domain grouping, and — the first clear deletion — **grant kinds**, once a module may only be granted what it exclusively owns ([`worked-provider.md`](worked-provider.md)). |
| ~~Is an interface a module, or a name?~~ | A **name**, and only where providers are genuinely substitutable. The adapter is what creates one; without an adapter there is a **tag**, which describes and does not bind ([`proposal.md`](proposal.md)). |
| ~~Where do domain modules fit?~~ | They do not. There is core infrastructure — concrete modules named individually, not flavourable, nothing standing in front of them. |
| ~~What happens to domain grouping?~~ | Superseded. Folders assert relationships; edges record them. What grouping was for is a tag and a query. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) is `proposed` and should be superseded rather than narrowed. |
| ~~Is there one kind of edge?~~ | **No — two.** *Presence*, where a thing must exist, and *instantiation*, where a provider makes something for a consumer and hands back credentials. Instantiation implies presence, not the reverse. |
| ~~When two modules provide one name, who chooses?~~ | Neither the consumer naming a node nor the consumer not caring. The consumer declares the **scope of its own need** — shared across its instances, or one each — the mesh binds, and the binding is written down and sticky. Where it is written follows the scope. |
| ~~Can several modules share one database?~~ | **No.** A module is granted only what it exclusively owns — no shared writes and no read role on another's store, because reading couples you to its layout just as firmly. |
| ~~What about a dashboard reading a dozen stores?~~ | **The rule is about contexts, not processes.** The mesh's own board reading the mesh's own store is the mesh showing its own data — not a boundary crossing. Everything inside a context reads its store freely; what is forbidden is a *different* context reading it. An earlier answer here was wrong. |
| ~~Can every registry consumer be served another way?~~ | **Largely dissolves.** Of eighteen direct consumers, the owner keeps its database, node appliers are already stopped by ADR 0005, and the bulk are **foreign tenants** — thirteen tables across three contexts — who need to move out rather than read differently. |
### Live
| Question | Why it is open |
|---|---|
| How many instances should a module have? | Not derivable and not global: one-per-mesh is right for the broker and wrong for a store a disconnected node needs. It is per-module and nothing in the schema says it. |
| Can two of something coexist? | `excludes` covers part of it. Two terminals are fine, two things wanting one port are not, two brokers might be either. |
| What does a provider hand back? | Credentials and an address for a store; a command for a terminal. Same relation, different shape crossing it. |
| Is provisioning one mechanism or two? | The mesh's own registry is provisioned **before there is a mesh**, so provisioning is part of the bootstrap and part of what the carried bundle expresses. At bootstrap the store is local; afterwards it is on another node. Same operation, both sides of a tier boundary. |
| Is a tool surface one relation with two audiences, or two? | 56 of 126 modules carry tools — more than carry a service — and what consumes them is an **agent**, not a module. |
| ~~Do the remaining cross-context reads want an interface or events?~~ | **Derived, not chosen.** Neither is SQL — that only ever runs against your own store. ADR 0004 makes disconnection ordinary, so anything that must work while disconnected cannot use a request and needs a local copy: a subscription. Anything where a stale answer is worse than none cannot use a subscription. |
| What does a consumer do about events it missed while disconnected? | Replay from a point, ask once for a full picture and resume, or rebuild. The question every projection has, and unanswered here. |
| What happens to a grant when its consumer is removed? | Dropping is data loss; keeping is a leak. ADR 0005's removal rule does not obviously carry, because the thing lives inside another module's state. |
| Is a declaration composed per node, from what that node reported? | Some configuration follows the hardware. Either the host fills a blank — deciding, against ADR 0005 — or the control plane composes from the node's inventory first. |
| Would `excludes` and capability requirements actually be used? | Zero manifests declare either, which is equally consistent with *nobody needs them* and *nobody can express them*. |
| What does an exclusion mean for something already installed? | Refuse the install, or surface the conflict and let it be decided. |
| Are tiers a view of the graph, or a constraint on it? | If a tier is a computed level the word is a convenience. If *a tier may depend only on tiers below it* is to be enforced, it is a constraint and must be stated as one. |
| Where does resolution happen — the mesh, or the platform's package manager? | The mesh must model mesh-level edges. Whether it also resolves operating-system packages decides whether a solver gets written. |
| How far may the control plane be split? | A single board over several contexts works because they are contexts *inside* one control plane with one interface. If a context becomes its own deployable with its own interface, the board is coupled to N of them and the composition has nowhere to live that tier 3 permits. A constraint on splitting, worth knowing before splitting. |
| What does the pipeline schedule, once features are gone? | It schedules features today. The four categories they split into have different lifecycles, so the unit of work differs for each and needs naming. |
| Should placement leave the catalogue? | A provision pins itself to a named node in the manifest. Placement is an inventory decision, and having it in the catalogue means a second node cannot provide the mesh's store without editing its consumer. |
@@ -0,0 +1,138 @@
# The graph is not missing
Measured against `origin/main` of the code repository, 2026-08-26. Every manifest, read through
git refs rather than a checkout.
The effort was opened to ask whether the catalogue's missing structure is a graph. It is not
missing. **It exists, it is healthy, and three separate parts of the system already use it.**
That is the finding, and it changes what is worth asking.
## Finding 1 — the graph is already declared, and it is clean
| | |
|---|---|
| manifests | 126 |
| declare a dependency on another module | 64 |
| declare a requirement on a provision | 11 |
| **edges** | **103** |
| declare neither | 62 — 49% |
| **cycles** | **0** |
| **dependencies declared but absent** | **0** |
| deepest chain | 5 |
Half the catalogue is unconnected, which matches
[research 005](../005-domain-grouping/analysis.md)'s finding that fifty modules co-change with
nothing. The connected half is well formed: no cycles, nothing dangling.
## Finding 2 — it is already resolved, and already used
The platform SDK carries a dependency resolver that topologically sorts modules, and it is
called from three places: the tool loader at startup, the installer when syncing modules onto a
node, and the delivery coordinator when expanding what a change affects.
It also already does something the effort assumed would need designing: **a requirement on
another module's provision is treated as an implicit edge to that module**, so a consumer does
not have to declare the same relationship twice.
So *ordering by the graph* — which
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says
the control plane will do — is not a thing to build. It is a thing to call.
## Finding 3 — the most important edges in the mesh are invisible
The one place the graph is wrong, and it is wrong about the substrate.
A module that needs a database declares it like this:
```yaml
provisions:
- name: mesh-db
provider: postgres # ← a dependency on the postgres module
node: <a named node> # ← and where it must run
```
`provider:` names a module. It is a dependency, declared, in the manifest — and it sits inside
`provisions:`, which is what a module *offers*. The resolver reads `dependencies:` and
`requires:`, so it never sees it.
| | |
|---|---|
| provider references that name a real module | 4 |
| **invisible to the resolver** | **3, across 2 modules** |
Three edges is nothing, and they are the mesh's own database, the mesh's own broker, and the
work engine's database. The most load-bearing relationships in the system are the ones the
graph cannot see.
**The consequence is measurable.** Computing what a working mesh needs, from the declared
graph:
```
registry → sdk → mesh → meshware 4 modules, 4 levels
```
No database. No broker. A closure that is arithmetically correct and obviously wrong, and wrong
for exactly one reason: a field that means *depends on* is not read as one.
This is [04-ISSUES/003](../../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md)
again, in a new place — not a key nothing reads, but a key read as something other than what it
means.
## Finding 4 — the resolver continues past faults it should stop on
Two behaviours, both contrary to
[ADR 0010](../../02-DECISIONS/0010-delivery.md):
- **A cycle warns and falls back to input order.** A cycle means no correct order exists; the
resolver proceeds with an arbitrary one and logs a line.
- **A dependency that does not exist warns and continues.** The validation is documented as
*non-fatal, logged as warnings*.
Neither has fired in the current catalogue — there are no cycles and nothing dangling — which
is why nobody has noticed. They are latent, and they are in the component that
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) makes
responsible for the ordering a host will apply without question.
## Finding 5 — placement is decided in the catalogue
`node:` in a provision pins it to a named node, in the manifest. Two modules do this today,
and they are the substrate ones.
Which node runs what is an inventory and placement decision — tier 2 by the skeleton's own
test. Having it in a manifest means the catalogue decides placement, and a second node cannot
provide the mesh's database without editing the module that consumes it.
## What this means for the effort
**The opening question — "what does the graph delete?" — has an answer: nothing, because the
graph is already there.** The premise was wrong, and finding that out is the effort's first
result rather than a setback.
The questions that survive are narrower and answerable:
| Missing declaration | Manifests using it today |
|---|---|
| `excludes` — installing A makes B unavailable | **0** |
| a required node capability | **0** |
| an interface, with adapters providing it | **0** |
Those are what a graph would *add*. What it would delete is a different and smaller list, and
the honest version of it is: nothing yet.
**And two defects worth fixing regardless of what else this effort concludes:**
1. `provider:` is a dependency edge and is not read as one. Fixing it makes the closure correct
— which is what [research 012](../012-the-minimum-viable-node/00-overview.md) needs in order
to answer what a one-node mesh requires.
2. The resolver continues past a cycle and past a missing dependency. Both should refuse.
## What was not measured
- **Whether the missing declarations would be used.** Zero manifests declare exclusions or
capabilities, and that is equally consistent with *nobody needs them* and *nobody can express
them*. Nothing here separates those.
- **Whether the interface-and-adapter idea has a consumer.** It is a good shape, and it is
argued for rather than measured.
- **What the closure should be.** Finding 3 says the computed one is wrong. It does not say
what the right one is; that needs the fix first.
+128
View File
@@ -0,0 +1,128 @@
# What a module can be
Every kind of thing the mesh has to install, run, own or know about, before deciding what a
manifest says. Written to be argued with: a case here that turns out not to exist should be
struck, and one that is missing is a hole in whatever schema follows.
The hard cases are at the end, and they are the point.
## The ordinary cases
**1 — A supervised service.** A container the mesh runs and keeps running. Nobody starts it;
it is simply up. *A relational store, a message broker, an object store, a dashboard.*
**2 — A system package with configuration.** Not a container. Installed into the machine,
configured through files, run by the service manager. *A firewall, a resolver, an overlay.*
Note: [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says applying
these is the host's job — so what the module contributes is the *deciding*, not the doing.
**3 — An application a person launches.** Installed on a node, started by a human, running only
while they use it. *An editor, a chat client, a file manager, a terminal.*
**4 — A command-line tool.** Installed, on the path, run when invoked. No service, no window.
*A formatter, a query client, a backup utility.*
**5 — A library.** Never runs at all. Consumed at build time by other modules. *An SDK.*
**6 — A one-shot task.** Runs once, changes something, exits. *A schema migration, a data
import, a seed.*
**7 — A scheduled task.** Runs repeatedly on a timer, exits each time. *A backup, a prune, a
report.*
**8 — An adapter.** Exists to make several unlike things look alike behind one name. *A model
provider behind an assistant interface.*
**9 — A standalone application in its own repository.** Same shape as any of the above; the
difference is only where its source lives
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)). Worth
listing because a schema that assumes a monorepo path would exclude it.
## The cases that break a naive schema
**10 — Something that is a service *and* an application.** A git forge is consumed by other
modules as a remote and a registry, *and* operated by a person through a web interface. A web
analytics service grants a tracking identity *and* is a dashboard somebody reads. Neither is a
service-or-application choice; both are true simultaneously.
**11 — Something that provides to others *and* consumes from others.** The store provides
databases and needs a filesystem. The forge provides a registry and needs a database. Provider
and consumer are not kinds of module; they are ends of edges.
**12 — Something the mesh installs that then becomes a node capability.** The container runtime
is installed *by* the mesh, and once it works, the node **provides** `container-runtime` to
everything else. So a module can change what its node provides. The node's provides-list is
therefore partly derived from what is installed on it, not only detected from what was already
there — and the two have to agree.
**13 — Something that must be adopted rather than installed.** The machine already has the
package manager, the container runtime, possibly the version control system, each with
configuration somebody chose. The module does not install it; it takes it over
([research 012](../012-the-minimum-viable-node/00-overview.md)).
**14 — Something that is a set, not a thing.** *Core infrastructure* is not installable — it is
a name for the concrete modules a working node needs. Whether that is a module whose only
content is `requires`, or a query over the graph, or a pinned list outside the catalogue, is
undecided and is one of the sharper questions here.
**15 — Something with one instance for the whole mesh.** There is one mesh database, not one per
node. Assigning it to two nodes is not redundancy, it is two meshes. Contrast with a terminal,
where per-node is the only sensible reading.
**16 — Something that may be installed several times over.** Two terminals coexist happily. Two
things wanting port 443 do not. Two message brokers might be fine or might be a split brain,
and nothing in *provides* and *requires* distinguishes those.
**17 — Something that is not software at all.** A firewall policy. A DNS record. A certificate.
It owns no binary, runs nothing, and is entirely *desired state* — which is the one case that
fits the host's declaration model exactly and fits an installable-package model not at all.
**18 — An agent.** The mesh's own premise is that agents are participants. An agent has an
identity, a licence, a node it runs on, and work it does. Whether that is a module, a record in
the control plane, or something else is not obvious, and getting it wrong shapes everything
about how agents are assigned.
**19 — The host itself.** Tier 0 installs everything else and is installed by hand. It is not a
module, and a schema that cannot say so has a bootstrap problem hiding in it.
**20 — Something the mesh depends on and does not control.** A domain registrar, an upstream
resolver, a certificate authority, an electricity supply. Almost certainly not modules — but
the mesh's health depends on them, and they are the reason a node can be perfectly configured
and still not work.
## What varies across the cases
The list above matters less than this. These are the axes a manifest has to express, and each
one is a question the schema must answer or deliberately refuse.
| Axis | Range | Sharpest case |
|---|---|---|
| **Does it run?** | supervised · launched by a person · once · on a timer · never | 5, 6, 7, 17 |
| **Who starts it?** | the mesh · a human · nothing | 1 vs 3 |
| **Where does it come from?** | container image · system package · our source · already on the machine | 12, 13 |
| **Does it provide to other modules?** | a resource · an abstract name · nothing | 8, 11 |
| **Does it hold state?** | yes, and it matters where · no | 1 vs 3 |
| **How many instances?** | one per mesh · one per node · many per node | 15, 16 |
| **Can two coexist?** | yes · no · only with different settings | 16 |
| **Is it installed or adopted?** | installed · adopted · either, depending on the machine | 13 |
| **Does installing it change what the node provides?** | yes · no | 12 |
**Two of these are not in any current thinking**, and both come from the hard cases:
*how many instances* (15) and *can two coexist* (16). `excludes` covers part of the second and
nothing covers the first.
## Questions the cases raise
- **Is "runs" a property or a kind?** The axes suggest a property — one schema, with a field
saying how it runs, `never` included. The alternative is several kinds of module with
different schemas, which is the taxonomy [research 011](00-overview.md) already rejected once
for services and applications.
- **Is an agent a module?** (18) If yes, the schema carries identity and licensing. If no, the
mesh has two catalogues.
- **Is core infrastructure a module?** (14) A module whose only content is `requires` is either
elegant or a category pretending to be a thing — the same trap `database` was.
- **What names one-per-mesh?** (15) Nothing in provides, requires or excludes says it, and
getting it wrong means two of something that must be one.
- **Where does a policy live?** (17) Pure desired state fits the host's declaration exactly.
Whether it is a module at all, or something the control plane derives and no catalogue entry
exists for, is open.
@@ -0,0 +1,131 @@
# What a feature is, and what it splits into
Measured against `origin/main` of the code repository, 2026-08-26.
The operator wants the feature concept gone.
[Research 006](../006-mesh-from-scratch/00-overview.md) left it open — *"does `feature`
survive? The skeleton splits it in two and argues the conflation is what makes the delivery
pipeline hard to reason about."*
This is what it actually is, and what it turns into.
## What it is today
A **feature** is a kind of content a module can carry, detected from what its directory
contains rather than declared. Twenty-one of them, each with a handler that owns its whole
lifecycle:
```
configs dist-assets events hooks migrations migration-artifact npm npm-install
prerequisite-env prerequisite-packages prerequisite-provision provision-migrations
provision-seeds seeds service systemd tools verifiers verify vhost detect
```
Each moves through **six stages**: build, publish, install, configure, start, verify.
## Finding — every handler implements every stage
The structural evidence, and it is not a style problem.
| handler | stages it implements |
|---|---|
| `configs` | build · configure · install · start · verify |
| `service` | build · configure · install · start · verify |
| `systemd` | build · configure · install · start · verify |
| `tools` | build · configure · install · start · verify |
| `vhost` | build · configure · install · start · verify |
| `migrations` | build · configure · install · start · verify |
| `npm` | build · publish · install · configure · start |
`configs` writes files onto a node. It has nothing to build, and it has a build stage.
`npm` publishes a package to a registry. It has nothing to start, and it has a start stage.
**One interface spans build-time and apply-time, so every kind of content must implement both
halves and most of them do nothing in one.** That is the conflation, and the emptiness is what
makes the pipeline hard to reason about: a stage that does nothing and a stage that failed to
do anything look identical from outside.
## What it splits into
The twenty-one are not one kind of thing. They are four, and they belong to different tiers.
**Artifacts — built once per version, then published.** `npm`, `dist-assets`,
`migration-artifact`. Nothing about a node is involved; the output is a thing that exists in a
registry. **Tier 2, delivery.**
**Resources — desired state on a machine.** `configs`, `service`, `systemd`, `vhost`, `tools`.
Applied, converged, idempotent — which is exactly what
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
already describes and what the host already does. **Tier 0.**
**Actions — run once, against something that is not this machine.** `migrations`, `seeds`,
`provision-migrations`, `provision-seeds`, `hooks`. A migration runs against a database, and the
database may be on another node entirely. Neither an artifact nor node state, which is why they
sit awkwardly in a scheme built for both. **Tier 2, and the operator wants seeds gone.**
**Checks — assertions, not changes.** `prerequisite-env`, `prerequisite-packages`,
`prerequisite-provision`, `verify`, `verifiers`, `detect`. The prerequisites are **requirements
in disguise** — a module saying what must be true before it can be installed, which is precisely
what an edge in the graph says. The verifiers are the read-back the host already performs.
## So the answer is: it splits, and the split is a tier boundary
**`feature` is one word for four things spanning three tiers.** That is why every handler
implements every stage, why half of them are empty, and why the pipeline is hard to reason
about.
Nothing replaces it, because it was never one concept:
| Was a feature | Becomes | Whose |
|---|---|---|
| npm, dist-assets, migration-artifact | an **artifact** | delivery |
| configs, service, systemd, vhost, tools | a **resource** in a declaration | the host |
| migrations, hooks | an **action** against something else | delivery |
| seeds | *nothing* — the operator wants them gone | — |
| prerequisite-* | an **edge** in the graph | the catalogue |
| verify, verifiers | the host's **read-back** | the host |
## Tools are a facet, and their consumer is not a module
The most common content in the catalogue: 56 of 126 modules carry a tool surface, more than
carry a service.
It survives the split, and it does not fit either half cleanly. A tool is not an artifact and
not node state — it is a **contract the mesh publishes on a module's behalf**, and what consumes
it is an **agent**, not another module.
That is a second audience, and the design has only described one. A module `provides` things
other modules require; a module also `provides` things agents call. Same word, different
consumer, different lifecycle — a tool appears when the module is assigned somewhere and
disappears when it is not, and nothing in the graph edges says so.
Whether that is one relation with two audiences or two relations is undecided, and it is the
kind of question that is cheap now and expensive later.
## What is lost, and should not be
**Detection.** Features are detected from the directory rather than declared, and the reason is
good: *a declared list and the directory it describes drift, and the directory is the one that
is true.* That property is worth keeping whatever the concept is called — a module that says it
has migrations and has none, or has them and does not say so, is a fault nobody sees until it
matters.
Under the split, detection still applies: what a module **contains** is read from what is there.
What it **requires**, **provides** and **excludes** is declared, because none of that is visible
in a directory.
That line is worth stating precisely, because it is the one the current design got right:
**content is detected, relationships are declared.**
## Open
- **Is an action a resource?** A migration is not node state and not an artifact. It could be a
resource type the host applies with the target being a database rather than a machine — which
would collapse the third category into the second, at the cost of the host reaching something
that is not the machine it is on. That cost looks too high, but it has not been argued.
- **Where do hooks go?** They are arbitrary code a module runs at a stage — the escape hatch. A
design with no escape hatch is either very good or has not met reality yet, and this one has
not.
- **What does the pipeline schedule, once features are gone?** It currently schedules features.
If the four categories have different lifecycles, the unit of work is different for each, and
what the coordinator orchestrates needs naming.
@@ -0,0 +1,167 @@
# One kind of edge
> **Superseded in part by [`worked-provider.md`](worked-provider.md).** Working postgres through
> completely shows there are **two** kinds of edge, not one: *presence* — the thing exists and is
> reachable, nothing created — and *instantiation* — the provider is asked to make something for
> this consumer and hands back credentials. Instantiation implies presence; presence does not
> imply instantiation. Everything else below stands; the claim in the title does not.
A design, not an account of what exists. [`analysis.md`](analysis.md) measured the current
catalogue and its value here is two lessons rather than its machinery: a field that means
*depends on* should say so, and placement does not belong in a manifest.
## The idea
**A module provides names. A module requires names. That is the only edge.**
Everything the effort listed as separate declarations turns out to be one relation with
different kinds of name on either end.
```
postgres provides postgres
kitty provides kitty, terminal
xterm provides xterm, terminal
anthropic provides anthropic, ai-assistant
meshboard requires postgres, lavinmq
vscode requires terminal, display-server
```
A name is either **concrete** — a module's own name, so `requires: postgres` means that module
and no other — or **abstract**, so `requires: terminal` means whatever provides it.
That single relation absorbs three things the effort had listed separately:
| Was going to be | Is |
|---|---|
| requires another module | requires a concrete name |
| requires a resource | requires the module that provides it, by name |
| an interface, with adapters providing it | an abstract name, legitimate only where an adapter makes providers substitutable |
The interface stops being a kind of module. It is a name with more than one provider *and a
contract they all satisfy*, and nothing has to declare that it is one.
### An abstract name is only legitimate when providers are actually substitutable
The test, and it is a strict one: **can a consumer be switched from one provider to another
without changing?**
`terminal` passes. Anything that runs a command in a terminal works, and a consumer never
learns which one it got.
**`database` fails, and it is the example worth keeping.** A module connecting to Postgres does
not connect to MongoDB, or to SQL Server, or to MariaDB. Different wire protocol, different
dialect, different driver. A consumer that declared `requires: database` and was handed any of
them would break — so the name promises something no provider can deliver, and the resolver
would satisfy a requirement that is not satisfied.
That failure has a shape this repository already knows: something declared, accepted, and not
true. It is worse here than usual because the graph would report success.
**The adapter is what creates an interface.** `ai-assistant` is a legitimate abstract name
exactly when adapters exist to normalise the providers behind it. Without an adapter there is
no interface — there is a category, and a category is not an edge.
So `postgres` is required by name, and a module that could genuinely work with several stores
requires whichever it actually speaks to.
### Categories are catalogue metadata, not structure
*Database* is still a useful word — for finding things, for a person browsing what the mesh can
host, for grouping in an interface. It is a **tag**.
Tags describe. Edges bind. Keeping them apart is what stops the catalogue acquiring a second
kind of relationship that looks like a dependency and is not — which is what a folder named
after a domain already was.
## The node provides names too
The move that makes capabilities stop being a separate system.
A node's profile is a set of provided names. `display-server`. `container-runtime`.
`amd64`. A module requiring `display-server` is satisfied by **the node**, exactly as a module
requiring `postgres` is satisfied by another module.
So there is one resolution rather than two: *is this name provided by anything available here?*
A graphical application cannot be installed on a node with no display server for the same
reason, and through the same code, that it cannot be installed without its libraries.
And it means the host's capability detection — which already reports what a machine can be
asked to do, with a reason for each verdict — **is the node's provides-list**. It was built to
be read by a person; it turns out to be an input to resolution.
## What a module declares
```yaml
name: meshboard
provides: [meshboard]
requires: [postgres, lavinmq, container-runtime] # by name: it speaks their protocols
excludes: []
tags: [observability] # for finding it, never for resolving it
runs: # what applying it means
- container: ...
constraints: # what must be true of a node, never which node
- architecture: amd64
```
`postgres` and `lavinmq` are concrete because this module speaks their wire protocols and would
break against anything else. `container-runtime` is abstract and satisfied by the **node**.
**`excludes`** is the one genuinely new relation: naming something that cannot coexist with
this. It is not derivable from requires and provides, and without it two modules that both
provide `terminal` — or two that both want port 443 — look independent right up until
installing the second breaks the first.
**`constraints` are not placement.** They say what must be true of a node, never which node.
Which node runs what is an inventory decision, and the measurement found the current catalogue
deciding it in the manifest — a module pinning its database to a named node, so that a second
node cannot provide it without editing the module that consumes it. That is the mistake this
separation exists to avoid.
## What it deletes
The question the effort opened with, answered for the design rather than for what exists.
- **The distinction between requiring a module and requiring a resource.** One relation.
- **The interface as a kind of thing.** A name with several providers.
- **Capability checking as a separate mechanism.** The node is a provider.
- **The domain module** — a `networking` module that exists to gather a firewall, a resolver and
a proxy under one name. It came from an older shape and does not fit: there is no such thing
to install. There is **core infrastructure**, which is a set of concrete modules named
individually — a firewall, a store, a resolver — with no flavour and no grouping module
standing in front of them.
- **Domain grouping as structure** ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
Folders assert relationships; edges record them. What grouping was for — finding things,
seeing what belongs together — is a **tag** and a *query* over the graph, neither of which
anybody has to keep true by hand.
- **Tiers as a separate concept**, possibly. A tier is a level in the graph, and levels are
computed. Whether the coarse boundary is still worth naming is below.
## What it does not delete, and should not
**A resolver still has to exist**, and it will need version constraints, conflict handling and
a story for when two modules provide one name and nothing says which to use. That is the
long-solved and easily-botched part, and the design should say what it **delegates** to the
platform's package manager rather than reimplementing.
## Open
- **When two modules provide one abstract name, who chooses?** A node with both `kitty` and
`xterm` satisfies `terminal` twice. Whether the choice is a node setting, a mesh setting, or
an explicit pin is the substance of the interface idea, and this proposal does not settle it.
It is a smaller question than it was: the strict substitutability test means the consumer
genuinely does not care which it gets, so the choice is about preference rather than
correctness.
- **Are tiers a view of the graph, or a boundary that survives it?** If tiers are levels, the
concept is derived and the word is a convenience. If the tier rule — *a tier may depend only
on tiers below it* — is meant to be enforceable, it is a constraint on the graph rather than
a description of it, and it has to be stated as one.
- **What does a provider hand back?** A consumer requiring `postgres` needs credentials and an
address; one requiring `terminal` needs a command. The requirement is satisfied by the same
relation in both cases, and what flows across it is not the same shape. Whether that lives in
the name, beside it, or in what the provider returns is undecided.
- **What does a node provide that is not a capability?** Its architecture is a provided name
under this scheme, and so is its operating system. That may be elegant or may be one
abstraction too far; nothing here tests it.
@@ -0,0 +1,491 @@
# A provider, all the way through
One module, worked out completely, because it is the case that breaks the tidy version. It
looks like *a container that runs a database* and it is at least nine things.
Postgres is the example. **The shape is not specific to it** — see the end.
## What it carries
**1 — A supervised container.** An image, a version, a data volume, and configuration. Easy,
and the only part the phrase "a docker service" describes.
**2 — Persistent state, and where it lives matters.** The volume is the database. Moving this
module between nodes is not rescheduling; it is a migration. Almost nothing else in the
catalogue has this property, and nothing in `provides` / `requires` expresses it.
**3 — Configuration that is partly the machine's.** Tuning follows the hardware — memory,
storage. A declaration generated centrally cannot know those, and
[research 012](../012-the-minimum-viable-node/00-overview.md) says the machine's own values win
on conflict. So some of this module's configuration is *derived from the node it lands on*.
**4 — A tool surface.** It exposes capabilities agents can call — query, list, provision. That
is not a resource on a machine and not an artifact; it is a contract the mesh publishes on the
module's behalf.
**5 — A provisioner.** The part that matters, and the one below.
**6 — Its own bookkeeping.** The provisioner must remember what it granted to whom, or it
cannot revoke, rotate or clean up. So a module that provides state to others *also* holds state
about its providing — and that state is not the database's data.
**7 — An exposure decision, per node it runs on.** Reachable from the machine only, from the
local network, or publicly. That is a property of *this assignment*, not of the module: the
same module on two nodes may answer differently.
**8 — Credentials it generates.** Per consumer, and they have to reach the consumer. Which
means a provisioning edge carries a payload, and the payload is a secret.
**9 — Health that is not "the container is up".** A container running and a database accepting
connections are different facts, and the second is the one anything cares about. This is the
host's read-back rule, at a distance.
## The provisioner is a second kind of edge
The tidy version of this effort said: *a module provides names, a module requires names, that is
the only edge.* A small game wanting to store data shows it is not.
```
my-cool-game requires postgres # I speak its protocol, it must exist
my-cool-game requires a database FROM postgres, called my-cool-game
```
The first is **presence**: the thing exists and is reachable. Nothing is created; nothing flows
back. `vscode requires terminal` is this, and so is `requires container-runtime`.
The second is **instantiation**: the provider is asked to make something *for this consumer*,
and hands back what the consumer needs to use it. A database, a user, a password, an address.
They differ in every way that matters:
| | presence | instantiation |
|---|---|---|
| creates something | no | yes, one per consumer |
| carries a payload back | no | credentials, an address |
| can be revoked | — | yes, and must be when the consumer goes |
| provider holds state about it | no | yes — who was granted what |
| satisfied by | anything providing the name | that provider, specifically |
**This is the mesh's actual power**, in the operator's words: a small game declares it wants a
database and the mesh makes one. Nobody creates a user by hand, nobody pastes a connection
string. That is worth being precise about rather than folding into a single relation because
one relation is prettier.
## What that costs the design
**The proposal's "one kind of edge" is wrong**, and the current system already knew: it has
`dependencies` for presence and `requires: provision:` for instantiation, with the resolver
deriving a presence edge from every instantiation edge. [`analysis.md`](analysis.md) recorded
that derivation as a convenience. It is not — it is the correct relationship between two
genuinely different relations.
So: **two kinds of edge, one graph.** Instantiation implies presence. Presence does not imply
instantiation.
## Which provider, when two nodes run one
Asked directly, because two nodes can each run a relational store and a consumer has to be
served by one of them. Neither obvious answer is right.
**Not "the consumer names the node."** That is placement in the consumer's manifest — a small
game edited because a database moved, which is the fault
[`proposal.md`](proposal.md) separates constraints from placement to avoid.
**Not "the consumer does not care" either.** For presence it genuinely does not: a terminal is a
terminal. For instantiation it cares permanently, because the data lands in exactly one store
and the wrong choice is discovered long afterwards.
**What the consumer does know is the scope of its own need.** Not which node — how many of the
thing it wants, relative to itself:
| Scope | Means | Example |
|---|---|---|
| **shared** | one instance serves every instance of this consumer | the mesh's own registry: every node reads the same rows |
| **per instance** | each instance of this consumer gets its own | a local cache, a per-node queue |
That is a property of the consumer, expressible without naming anything. And it is the thing
that actually decides: a shared need cannot be satisfied by a provider each node runs
separately, and a per-instance need should not be satisfied by a shared one.
**Then the mesh binds, and the binding is written down.** Not recomputed: a resolver that
re-derives which store serves a consumer will one day derive a different answer and relocate a
database, so the binding is made once and changed only deliberately.
**Where it is written down follows the scope.** A shared grant belongs to the *module* and every
assignment of it references the same one — which is the answer for one module installed on two
nodes wanting one database between them. A per-instance grant belongs to the *assignment*. Same
relation, two homes, and which home is not a detail: it is what makes two instances share
something or not.
The pieces that follow, none of them settled here:
- **When several providers satisfy the scope**, something chooses — most plausibly locality,
preferring a provider on the same node. That is a default, and it must be overridable, because
the reason to override it is exactly the reason nobody anticipated it.
- **A binding is a thing that can be wrong.** Once recorded it can be inspected, and a consumer
bound to a store on a node that no longer exists is a question somebody can be asked rather
than a failure at connect time.
- **Moving a binding moves data.** Whatever the mechanism, changing it is a migration and not a
configuration change, and a design that lets it look like the latter will lose something.
## Provisioning is early, not late
An assumption worth killing: that provisioning is something the control plane does for
consumers once a mesh is running.
**The mesh's own registry is a provisioned database.** So is its virtual host on the broker.
Neither exists until something creates them, and nothing in the mesh works until they do. The
order is:
```
1 the store runs from the bundle the host carries
2 a database is created in it a provisioning step
3 the mesh's own schema is applied a migration, against that database
4 the control plane starts and only now is there a mesh
5 everything else is provisioned the ordinary path
```
Steps 2 and 3 happen **before there is a mesh to do them**. So provisioning is not a
control-plane service that consumers use; it is part of the bootstrap, and part of what the
carried bundle has to be able to express.
**Which strains what a declaration is.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
has the host applying *declared state on this machine*. A database inside a running store is not
a file or a unit — and at bootstrap it is, at least, local: the store is on the same machine as
the host applying the bundle.
Later it is not. A consumer on one node provisioned from a store on another is the ordinary
case, and reaching it is not the host's job.
**Resolved as two mechanisms, which is the answer rather than a compromise**
([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)). The host
runs bootstrap actions locally from the bundle; the control plane provisions across the mesh
afterwards. Different actors, different scopes, different trust paths — so there is no single
operation with a tier boundary running through it.
## Several modules, one database — and the case for refusing
Asked, then reconsidered by the operator: *maybe we should not allow it.*
The permissive version was a per-consumer **schema** inside a shared database — its own
namespace, its own migrations, revocable by dropping the schema, with a cross-context join
possible but deliberate.
**The stricter version is better, and it goes further than schemas.**
> **A module is only ever granted a resource it exclusively owns.**
No shared writes. And **no read-only role on another module's database either** — reading
another context's tables couples you to its layout exactly as firmly as writing them does, and
the coupling is harder to see because nothing breaks until the owner changes a column.
That is what [`how-we-build`](../../00-META/how-we-build.md) §4 already says: *contexts integrate
through the record, never through a shared schema.* The permissive version kept the letter of it
and left the temptation in place, and the path of least resistance wins eventually. A boundary
that is merely inconvenient to cross is a boundary that gets crossed.
### What it costs
**Cross-module reporting.** Anything wanting to know what several modules hold can no longer
join across them. It consumes their events, or calls their interface, and neither is as
immediate as a query.
That cost is the point rather than a regrettable side effect — it is §4's whole argument, and
the mesh already has both mechanisms: an event stream every context publishes to, and a tool
surface every module exposes. What gets harder is the thing that was making work belonging to
one context keep having to be implemented in another.
**One more connection per consumer.** A dozen modules means a dozen databases rather than a
dozen schemas in one. For a relational store this is unremarkable; it is worth stating only so
nobody discovers it as a surprise.
### What it deletes
The effort has been looking for what the design *removes* rather than adds, and this is the
first clear instance:
- **Grant kinds.** There is one — an exclusive resource. No schema grants, no read roles, no
scoping rules for who may see what inside a shared thing.
- **The question of who owns which table**, and with it the guessing at revocation time.
- **Cross-module migration ordering.** Two modules migrating one database need their migrations
ordered against each other. Exclusive ownership means a module's migrations are ordered only
against itself.
- **A whole class of permission modelling** that a shared store would otherwise need.
### The rule is about contexts, not processes
An earlier version of this file argued that a dashboard reading a dozen stores was caught by the
rule, because a dashboard is a surface and surfaces speak to an interface. **That was wrong, and
it drew the line in the wrong place.**
The mesh's own board showing nodes, modules and deployments is not a separate context reaching
across a boundary. It is the mesh showing its own data. Reading that store is not a violation
however it is done, and requiring it to go through an interface to reach facts its own context
owns would be ceremony.
**The rule is: a context is granted what it exclusively owns.** Everything inside that context —
its service, its surface, its tools — reads it freely. What is forbidden is a *different*
context reading it.
Which is exactly what the count below shows: the problem was never surfaces. It was three other
contexts keeping their tables in the mesh's database.
**And one surface over several contexts is normal.** The board visualises the mesh, the work
engine, the knowledge base and more, and the alternative — a separate web application per
context — is worse for everyone who uses it. That is not a compromise with the rule; composing
several sources into one view is what a surface *is*.
What it changes is only where it reads from: each context's **interface**, not each context's
**store**. Most of that already exists — 56 of 126 modules carry a tool surface, more than carry
a service.
**And the unified board is what keeps those interfaces honest.** If a view cannot be built from
a context's interface, that interface is inadequate — discovered in the one place where it is
cheap to notice, rather than the first time something else needs the same data and quietly
reaches for the store instead.
**But "the board calls each context's interface" is not quite it either**, and the objection is
right: if every context runs its own service with its own interface, the board is coupled to N
of them instead of N schemas, something has to compose them, and composition is logic — which
tier 3 says a surface does not hold. That moves the problem up a layer rather than solving it.
**The skeleton already answers this and the argument above talked past it.** `work` and
`knowledge` are not separate services; they are **contexts inside the control plane**, alongside
the record, inventory, delivery and the rest — and `api` is listed there as *the one interface
every surface speaks to*.
So the board speaks to **one** interface. Behind it the contexts stay separate: separate stores,
integrating through the record. But they are one tier, one repository, one deployable, and
coupling *within* a tier is not what the tier rule forbids.
Which resolves the objection rather than deflecting it: the problem does move up a layer, and
the layer it moves to already exists and has this as its job.
**The caveat is load-bearing.** This holds only while the contexts are not separate deployables.
The moment one becomes its own service with its own interface, the board is back to N clients,
something must compose them, and the composition has nowhere to live that tier 3 permits. That
is a real constraint on how far the control plane may be split, and it is worth knowing now
rather than discovering it by splitting.
If composing even one interface turns out too slow, the answer is a projection the board owns
and keeps current from events — not access to somebody else's tables.
### Checked against the real consumers
The rule's survival turned on whether every reader of the mesh's own registry could be served
some other way. **Eighteen consumers open a direct connection to it.** Four groups, and only one
of them is work.
| Group | What happens under the rule |
|---|---|
| **The owner and its machinery** — the mesh module, the SDK, the environment and configuration synchronisers, secrets | Nothing. It owns the database. |
| **Node appliers** — the overlay, the shell daemon, the resolver | **Already resolved.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) stops the host querying the mesh database, decided for tier reasons with nothing to do with this. |
| **Foreign tenants** — the work engine (10 tables), the knowledge base (2), pipeline logs (1) | They need **their own database**. They are not reading the registry; they are storing their own data in it. |
| **Genuine cross-context reads** — the work engine reads `nodes`; two others read a handful | The only ones needing an interface or events. |
**Thirteen foreign tables live in the mesh's registry database**, belonging to three separate
contexts. That is [`how-we-build`](../../00-META/how-we-build.md) §4's shared schema, counted.
**So the question was the wrong shape.** The bulk of the problem is not readers needing a new
route to data — it is **tenants needing to move out**. Tasks, agents and teams have nothing to do
with nodes and modules; they are co-located by history. Give that context its own database and
its dependency on the registry shrinks to a single table.
What remains is a handful of genuine cross-context reads, small enough to enumerate rather than
estimate. **The rule holds.**
### Request or subscription, and what decides
The remaining cross-context reads need one or the other. **Neither is SQL** — under exclusive
ownership a module runs SQL against its own database and nothing else, whatever transport a
query might travel over. Both options are the mesh's own channel, and both ride the broker
([ADR 0002](../../02-DECISIONS/0002-nodes-communicate-over-a-broker.md)), so the transport is
not the distinction.
**The distinction is where the answer lives when you need it.**
| | request | subscription |
|---|---|---|
| you ask | at the moment you need to know | never — you are told |
| the answer lives | on the other side | in your own store |
| freshness | always current | as current as the last event you received |
| when the other side is down | you cannot answer | you answer from your copy |
| what you must handle | a round trip that can fail | events you missed while you were down |
**What decides is not taste.** [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)
makes disconnection an ordinary situation rather than an exception. So:
> **Anything that must keep working while disconnected cannot use a request** — there is nobody
> to ask. It needs a local copy, which means a subscription.
And the converse: anything that must be *correct at the instant of asking*, where a stale answer
is worse than no answer, cannot use a subscription. A display can lag. A decision about whether
a grant is still valid cannot.
That turns an apparently open question into a derived one. What remains genuinely open is
narrower: **what a consumer does about the events it missed** while it was disconnected — replay
from a point, ask once for a full picture and resume, or rebuild from scratch. That is the same
question every projection has, and nothing in the record answers it yet.
## A migration belongs to the consumer and runs on the provider
A game defines migrations. They run against the database the store granted **it**. So the
migration is:
- **owned** by the consumer — it is that module's schema, versioned with that module;
- **hosted** by the provider — it runs inside something the consumer does not control;
- **ordered** after the provisioning edge — there is nothing to migrate until the grant exists;
- **scoped** to the grant — the consumer's migrations touch its database and no other.
Ownership crosses the edge, which nothing in *provides* and *requires* expresses. And it is the
*action* category from [`features.md`](features.md) made concrete: not an artifact, not node
state, and not something the host can apply, because the thing it changes is not the machine.
It also gives a consumer's own install an internal order — **provisioned, then migrated, then
started** — that depends on an edge rather than on the module's contents.
## What still has no answer
**How many instances of postgres should exist?** One per mesh is wrong — a node that must work
while disconnected cannot depend on a database elsewhere. One per node is wrong — the mesh's own
registry is one thing, not one per node. So the answer is per-module, and nothing in the schema
says it. This is [`cases.md`](cases.md) axis *how many instances*, and postgres is the case that
proves it cannot be a global rule.
**What happens to a grant when the consumer is removed?** The game is uninstalled. Its database
still exists, holding its data. Dropping it silently is data loss; keeping it forever is a leak.
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says
the host removes what it applied and no longer declares — but this is not on the host, it is
inside another module's state, and the same reasoning does not obviously carry.
**Where does node-derived configuration come from?** (3) The control plane composes a
declaration, and cannot know this machine's memory. Either the host fills in a blank the
declaration leaves — which makes the host decide something, against
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) — or the control
plane reads the node's inventory first and composes with it. The second is consistent and means
a declaration is composed *per node from what the node reported*, which is a stronger claim than
anything recorded so far.
## Providing is not a substrate thing
The four pinned services are the obvious providers, and they are not a category.
| Service | What a consumer asks it for |
|---|---|
| a relational store | a database, a user, credentials |
| another relational store, different vendor | a database — **and not the same one** |
| a message broker | a virtual host, a user, permissions |
| an object store | a bucket and keys |
| an image registry | a repository |
| an identity provider | a client, a realm, a secret |
| an analytics service | a site, and a tracking identity |
| a low-code data platform | a base, and a token |
| an application platform | a project, which is several of the above at once |
| a mail server | a mailbox, an alias, credentials |
**Any hosted service can be a factory.** Providing is a facet a module may have, not a kind of
module it is — which is the same conclusion the effort reached about services and applications,
arriving from the other direction.
That kills the last reason to keep *provider* as a category. A module runs something, or grants
something, or both, or neither.
### Two stores, and why `database` still is not a name
Two relational stores from different vendors both grant *a database*. They are the sharpest
possible test of the substitutability rule from [`proposal.md`](proposal.md), and they fail it
completely: different wire protocol, different dialect, different driver, different client
library compiled into the consumer.
A consumer declaring `requires: database` and being handed either would break against one of
them. So the name promises what no provider delivers — and now with two real providers in the
catalogue rather than a thought experiment.
*Database* remains a **tag**. It is how a person finds both. It is not how a consumer names what
it needs.
## The same shape, three more times
The message broker has all nine. So does the object store, and so does the image registry. They
differ in what a consumer asks for — a database, a virtual host, a bucket, a repository — and in
nothing structural.
**A substrate service is a service plus a factory.** That is the whole pattern, and there are
four of them. It generalises past the substrate too: anything that grants something per consumer
has this shape, and anything that does not is the simpler case.
But two things differ *between* them, and both matter more than the similarity.
### The broker cannot be managed over the broker
[ADR 0002](../../02-DECISIONS/0002-nodes-communicate-over-a-broker.md) makes the broker the
channel every node takes work from, and
[ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md) makes it the security
boundary — everything a node applies arrives through it.
So the module providing the broker is also **the way modules are managed**. A declaration cannot
be delivered to it over itself, and reconfiguring it is done through the thing being
reconfigured. Nothing else in the catalogue has that property; the store is consumed by the
control plane but is not how the control plane *reaches* anything.
This is exactly what the carried bundle exists for
([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)): the broker is raised from
what the host carries, before there is a channel, because there is no other way to raise it.
Recorded here because it is a constraint on *one module*, not a general rule, and a schema with
no way to say so hides it.
### Two modules of identical shape want different instance counts
The broker is one per mesh — a single point of failure and a single point of trust, by decision
rather than by accident. The store cannot be: a node that must keep working while disconnected
([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)) cannot depend on a database
somewhere else.
Same nine properties, opposite answers. Which settles something the cases file left open: **how
many instances is not derivable from what a module is.** It is a decision per module, it has to
be declared, and nothing in `provides`, `requires` or `excludes` says it.
### And revocation differs in consequence
Revoking a database leaves data behind until something drops it — a leak, and recoverable.
Revoking a virtual host drops whatever had not been delivered — not recoverable, and silent.
The relation is the same and the blast radius is not, which is an argument for the provider
deciding what revocation means rather than the mesh applying one rule to all of them.
## The assignment is a third thing
Recorded because the operator tried the alternative and abandoned it: **modules were once
node-agnostic**, and it did not survive contact.
The worked example says why. Several of a provider's nine properties are not properties of the
module at all:
- **where its state lives** — a volume on a particular machine;
- **how it is reached** — the same module on two nodes may answer locally, on the network, or
publicly, and that is a per-assignment decision;
- **configuration derived from the hardware** — tuning follows the memory and storage of the
machine it landed on;
- **whether this instance is the one** a given consumer is provisioned from.
None of those belong in the catalogue, because they differ per node. None belong in the node,
because they are about this module. **They belong to the pairing**, and a design with only
modules and nodes has nowhere to put them — which is what "node-agnostic" ran out of.
So there are three entities, not two:
> **a module** · **a node** · **an assignment**, which is a module on a node and carries its own
> configuration
The current system already has this, arrived at the same way: environment values are stored per
module *and per node*, so a module's settings differ between the machines running it.
**This does not put placement back in the manifest.** A module still says what must be true of a
node and never which node ([`proposal.md`](proposal.md)). What changes is that the *result* of
placing it is a thing with its own state, rather than a fact recorded on one of the two ends.
And it makes the composed declaration question from above answerable: a declaration is built
from the module, the node's inventory, and the assignment between them. Three inputs, which is
why two were never enough.
@@ -0,0 +1,213 @@
---
status: active
initiated: 2026-08-26
touches:
- 02-DECISIONS/0005-the-node-host.md
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
- 03-DESIGN/01-to-be/05-the-node-host.md
- 03-DESIGN/00-as-is/05-runtime-and-installation.md
- 01-RESEARCH/011-the-module-graph/00-overview.md
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
- 02-DECISIONS/0088-the-foundation-filters-before-anything-listens.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
---
# 012 — The minimum viable node, and adopting what is already there
## What is being investigated
Two questions that turn out to be one:
**What is the bare minimum to run a one-node mesh?** Not the tiers as asserted, but the actual
closure — take the thing that must run, walk what it needs, and the set that comes back is the
answer.
**And how does a machine that is already in use become that?** A candidate node is not empty. It
has a package manager, probably a container runtime, possibly a git installation, each with
configuration somebody chose. The mesh must **own** those, and owning is not the same as finding
them present.
## Why
Building tier 0 reached a wall that looked like a packaging problem and is not.
The host can be told to run a container or install a package. Both need a file — an image, an
archive — and the question was where the host gets it. That framing produced a bad trilemma:
carry everything in the bundle, download at apply time, or have something push the files in
first. Downloading fails on the first node, which cannot fetch the image registry from the image
registry it is trying to start.
> **Qualified by [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md).** The
> reframing below still holds for what a *tailored installer* contains — the missing pieces for a
> given machine. It does **not** have to hold for container images: the installer fetches those
> by digest, because a real machine has a network and the sealed case is the lab.
**The reframing:** the machine is not offline. What matters is *when* the fetching happens. Move
it from apply time to **build time** — build the installer on a machine that has a network,
tailored to the target, and apply it on a target that then needs nothing. That is the same move
the lab already made for its router image, and the same property the delivery design already
claims: what ships is self-contained and a deploy touches no network.
Which makes the interesting question not *where do artifacts come from* but **what is missing
from this particular machine**, and that needs both of the questions above answered.
## What tailoring implies
- **The binary stays generic; the payload is tailored.** One static host per architecture. What
is machine-specific is the bundle it applies. Rebuilding the host per machine would buy
nothing.
- **Detection is the input, not a report.** What the host already reports about a machine —
its profile and inventory — is what the difference is computed against. This is the first use
of stage 1 by something other than a person reading it.
## Adoption
**Simply having a package installed is not enough.** If the mesh manages the container runtime,
it decides that runtime's configuration; a runtime found already installed carries settings
somebody chose, and those cannot be discovered by noticing the binary exists.
So a machine already in use is **adopted**: what is there is read, taken over, and thereafter
generated.
**This was the original path.** [`00-as-is/05`](../../03-DESIGN/00-as-is/05-runtime-and-installation.md)
records adoption of a pre-existing machine's configuration as the original mechanism, since made
legacy and explicitly out of scope for the lab. It returns here for a different reason than it
was dropped for, which is a thing to notice rather than to gloss.
**It creates a state that does not exist today.** [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md)
has managed files generated and never edited; adoption needs a one-time import before that rule
starts applying. Three states, and the middle one is new:
> unmanaged → **adopted once** → generated
**And it crosses a boundary just drawn.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
says the host never touches what it did not create — the rule that stops a converger deleting
what the mesh never put there. Adoption is the deliberate act of taking ownership of exactly
that. The rule needs a companion rather than an exception: *never, unless adoption made it the
host's*, with adoption being explicit, recorded, and visible in what the host says it owns.
## Nothing is taken over without keeping what was there
**Before adoption touches a file, the original is kept.** Adoption happens on machines somebody
is already using, and the configuration being taken over is configuration somebody chose. A
one-way door on a working machine is not an installation, it is a risk nobody agreed to.
This is a *never* rule rather than a courtesy, and it earns that by the same incident the mesh's
strongest rule already carries: the worst loss in this record came from a tool acting on a path
it did not own. Adoption is that act, made deliberate — which makes the safeguard obligatory
rather than optional.
What that requires, and what remains open: where the copy lives, whether it is recorded in what
the node knows about itself so that adoption is *visibly* reversible, and whether the mesh keeps
it forever or hands it back when it stops managing the thing.
## Adoption produces a briefing, not just a result
Proposed by the operator, and it answers a question this effort had open with two bad answers.
Adoption meets things a script cannot decide. A container runtime configured with one storage
driver and a mesh wanting another. A package pinned to a version somebody chose for a reason.
Local settings the mesh has no opinion about and no business discarding. Silently winning is
wrong in both directions; refusing outright makes a machine in use unadoptable.
**So adoption has two outputs.** What it did — mechanical, recorded, in the node's state. And a
**briefing**: what it found, what it took over, and what it could not resolve, written to be
read by a person or an agent, which is the first thing a session on that node has to work with.
Conflicts are **flagged, not resolved**. That is the same principle the declaration parser
already applies — name every problem at once, to somebody who can act on it — at a larger
scale, and applied to a case where refusing wholesale would be worse than proceeding.
### On conflict, the machine's configuration is kept
Decided by the operator, after first deciding the opposite — recorded that way because the
reasoning for each direction is the useful part.
Where the existing configuration and the mesh's disagree, **what is already on the machine
stays**, the conflict is flagged, and it is reconciled afterwards. Adoption always **completes**
— flags inform, they do not block — and *adopted with open questions* prevents nothing. The
node is a node.
**What this buys.** Adoption becomes non-destructive by construction. The class of conflict that
made the opposite rule dangerous — a storage driver against the filesystem it is actually on, a
data directory pointing at a mount that exists — cannot arise, because nothing tied to the
machine's physical reality is ever overwritten. A machine in use keeps working exactly as it
did.
**What it exposes, which is the mirror of what it fixes.** The mesh's configuration is not only
preference. Some of it is what a module needs in order to function at all. Keeping the machine's
version there produces a module that is installed and does not work — *an installed package is
not a capability*
([04-ISSUES/007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md))
arriving from a direction that issue did not anticipate. And a fleet where every node kept its
own settings is a fleet where a module works on one node and fails on another with nothing in
the mesh able to say why.
**The distinction that dissolves both rules.** Neither direction is right as a blanket, because
the question is not *whose configuration wins*. It is whether the module **requires** the
setting or merely **prefers** it — required contradictions cannot be kept without breaking the
module, and preferences should always yield to what is already there.
That is a property of the module's own declaration rather than of the adoption algorithm, which
makes it one more thing the graph would carry
([research 011](../011-the-module-graph/00-overview.md)). Until modules can say which of their
settings are load-bearing, adoption is choosing a default in the dark, and the default chosen
here is the one that does not break the machine it is adopting.
### The briefing carries an outcome, and the outcome is derived
Proposed by the operator: the report states plainly whether adoption succeeded, partly
succeeded or failed, and each line carries its own severity.
**Each line is marked, and the overall is the worst mark present.** Derived rather than stated
alongside, because two fields written independently drift — and a briefing reading *full
success* while carrying a failed line is exactly the fault this record keeps cataloguing. An
outcome computed from its lines cannot disagree with them.
| Mark | Means |
|---|---|
| `ok` | done, and verified |
| `kept` | a disagreement; the machine's value was kept and somebody should look |
| `unknown` | could not be determined |
| `failed` | could not be done — the node is not what was asked for |
**`unknown` is not a shade of success.** Adoption will meet configuration it cannot parse and
state it cannot read, and folding those into *fine* is the same move as reporting an installed
package as a capability. A thing nobody could determine is a thing nobody can rely on, and it
gets its own mark for the same reason a capability detector reports *why*.
**And this opens something the earlier rule did not cover.** *Flags inform, they do not block*
was decided about **conflicts** — where the mesh chose, deliberately, and the machine still
works. A **failure** is different in kind: not *we chose* but *we could not*. Treating both the
same makes a node where something the mesh needed never happened indistinguishable from one
where a log level differed. Whether a failed line still lets adoption complete is therefore
reopened by adding severity, and is not decided here.
## Adoption as a mode, for the migration
*2026-09-22.* The migration from the predecessor mesh gave the middle state a length. A machine
running the predecessor is **adopted** when the mesh comes up on it — the predecessor's control
stopped, the machine's firewall and files kept in force, the mesh opening what it needs through
them — and stays adopted while its modules migrate one at a time, until the operator **converges**
it. Measured on the control-node, and weighed against the alternatives, in
[*migrating a node that is in use*](migrating-a-node-in-use.md); decided in
[ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md).
## Open questions
| Question | Why it is open |
|---|---|
| What is the closure for a one-node mesh? | The skeleton asserts four pinned services. [Research 006](../006-mesh-from-scratch/00-overview.md) already asks whether it is four or five and does not answer. A graph gives a computed answer instead of an asserted one, which is [research 011](../011-the-module-graph/00-overview.md). |
| Is "tier" the same thing as a graph level? | Tiers were named as a bootstrap order. If the closure is computed, tiers may be a derived view of the graph rather than a separate concept — or they may be a coarser boundary that survives for a different reason. |
| ~~What happens when existing configuration contradicts what the mesh needs?~~ | **Answered** — the machine's configuration is kept, the conflict is flagged, and it is reconciled afterwards. |
| ~~Do flags block, or only inform?~~ | **Answered** — they inform. Adoption always completes, and the node is a node. |
| Can a module say which of its settings are load-bearing? | The question that dissolves the conflict rule rather than choosing a side. A setting the module *requires* cannot be kept from the machine without producing something installed and broken; a setting it merely *prefers* should always yield. Until a module can say which is which, adoption is defaulting in the dark. Belongs with the graph. |
| Does a `failed` line still let adoption complete? | *Flags inform, they do not block* was decided about conflicts, where the mesh chose and the machine works. A failure is *we could not*, which is different in kind — and treating them alike hides the worse one behind the commoner one. |
| How is a flagged conflict reconciled, and by whom? | The briefing hands it to a session. What that session is empowered to change, and whether the resolution is recorded so the next adoption does not re-raise it, is undecided. |
| ~~How long is a machine adopted?~~ | **Decided** ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)) — for as long as it is being migrated: a mode per node, recorded, ended by an explicit and previewed flip. |
| Where does the kept original live, and for how long? | Whether it is recorded in the node's state so adoption is visibly reversible, and whether it is returned when the mesh stops managing the thing. |
| What shape is a briefing? | Structured enough to be acted on, prose enough to be read. It is the first thing a session on a new node sees, which makes it an interface rather than a log. |
| Does owning a package mean owning its version? | Owning configuration and owning the package are different scopes. The second means the mesh decides which version is installed, and that decision then has to survive the machine's own package manager updating it. |
| How does a bundle stay true between building and applying? | It is built against a scan of the target. The machine can move between the scan and the apply, so the host has to verify rather than assume — and fail plainly when the bundle no longer fits. |
| What cannot be precomputed at all? | Anything built from source on the target still needs a toolchain and a network at that moment. Tailoring moves that cost rather than removing it, and *minimal viable* has to be honest about what it cannot ship ahead. |
| Does presence differencing understate the gap? | Knowing a package manager is installed does not say it is the version the mesh needs. A difference computed on presence alone is optimistic. |
@@ -0,0 +1,154 @@
# Migrating a node that is in use — adoption as a mode, not a moment
*2026-09-22. Measured on the machine that will be the control-node, which is running the
predecessor mesh today. Nothing below names it; the counts are its own.*
## The question
The mesh replaces a predecessor mesh that is running, on the same machines, with the services
people use. The control-node is decided: it is the machine that already carries the predecessor's
broker and build pipeline. So the question is not *where* the mesh starts but **how a machine
running the predecessor becomes a node of the mesh without its services noticing** — and then how
each of the other machines follows.
## The operator's proposal
Proposed by the operator, 2026-09-22, and the shape this document tests:
1. **Stop the predecessor's control on a machine** — its daemons that write configuration: the
network and firewall configuration above all, which decide what is reachable and what is
blocked. Its services keep running; only the control over their configuration stops.
2. **Bring the mesh up on that machine in adoption mode.** It takes custody of those files and
keeps what it finds in force.
3. **Migrate the modules one at a time**, data preserved, per the cutover procedure.
4. **Move to the next machine and repeat** — adopted first, keeping its local configuration, then
migrated.
5. **When every machine is migrated, flip adoption mode**, and the mesh takes full control of the
configuration it has been holding.
This is the research above made concrete. It keeps the conflict rule already decided here — *on
conflict, what is on the machine stays* — and gives the middle state, *adopted*, a length: not a
one-time import before generating starts, but a mode that lasts for as long as the machine is
being migrated, ended by an explicit act.
## What the machine actually looks like
Measured, read-only:
| | |
|---|---|
| Containers running | 60, all the predecessor's services and their stores |
| The predecessor's control | user-level daemons, separate from the services; none of the 20 running system services is the predecessor's control |
| Firewall | the predecessor's, active: 54 incoming rules and 52 forwarding rules, each served port allowed explicitly — how a default-deny firewall reads |
| Files the predecessor's configuration sync writes | 12, of which 3 are system files (an ssh server drop-in, the package manager's configuration, one service's configuration); the rest are the operator's shell and agent files |
| Per-service configuration | environment and composition files per service, written by the predecessor's service tooling |
**Stopping the predecessor's control stops nothing that serves.** The services are containers and
system units that run without it; what stops is the rewriting of their configuration. Nothing
changes on the machine until something else writes.
## What collides, measured rather than assumed
An earlier note assumed the mesh's foundation could not stand beside the predecessor because both
want the store's and the broker's standard ports. **The measurement says otherwise.** The
predecessor publishes its own store on a non-standard port and its broker on another; the standard
ports the foundation binds for its store and its bus are free.
What does collide:
| The mesh wants | Held by | When |
|---|---|---|
| the registry's port | the predecessor's registry | at genesis — the foundation raises a registry |
| the broker's management port, on loopback | the predecessor's broker | at genesis |
| the private network's port | the predecessor's own tunnel | at genesis on a control-node that is the private network's hub; otherwise when the node is placed on it |
| the web ports | the predecessor's reverse proxy | when the route proxy is assigned |
| the resolver's port | a resolver the predecessor runs | when a resolver module is assigned |
On the control-node, which is the private network's hub, three of these fall at genesis and cannot
be deferred; two only when a particular module is taken, the moment its predecessor stops anyway.
Today the foundation's ports are **fixed**: written in the installer's bundle and in the
catalogue's manifests, so a collision is found when a container fails to bind, not before — and a
port changed at genesis would be changed back when the foundation is adopted as modules, since the
applier recreates a container whose declared spec differs. The private network's address range
must also stay clear of the range the predecessor's tunnel uses; on the machines measured they are
distinct.
## What would break if the mesh came up as it is today
**The firewall.** Genesis loads a base ruleset — drop anything undeclared — in its own table
([ADR 0088](../../02-DECISIONS/0088-the-foundation-filters-before-anything-listens.md)). The
predecessor's firewall is a different table. The kernel runs every base chain registered at the
same hook, in priority order: an accept ends only its own chain and the packet goes on to the next,
and a drop in any is final — whether the other firewall's chains are nftables or legacy iptables.
So the
mesh's base ruleset would drop everything the predecessor's firewall allows and the mesh has not
declared — every web, mail and database port in the table above — the moment genesis ran.
**The files.** The host writes a declared file whatever it finds at the path, reporting it as
updated. A file the predecessor left — the ssh server drop-in, the resolver's configuration — is
replaced the first time a module declaring that path is assigned, before that module's service
has moved.
**The container names.** The applier keys a container on its name. A catalogue module whose
container carries the same name as the predecessor's service it replaces takes that container over
the moment it is assigned: today, *assigning a module is migrating it*, never a preparation.
**The published ports.** The foundation publishes its ports on every interface, and a published
container port reaches the container through the forwarded path, not the incoming one. A firewall
that filters only incoming traffic never sees it. The base ruleset is what keeps the store
unreachable from outside today — and it is the thing that cannot be loaded on this machine.
## The pipeline freezes while the control-node migrates
The predecessor's build pipeline and its coordinator run on the control-node. Stopping its control
there stops the predecessor's updates for **every** machine it manages, until the migration is
done. Their services keep running; they receive nothing new. That is the price of the proposal and
it is worth stating, not a reason against it: the migration is the period in which the predecessor
is being replaced, and it does not need to keep changing.
## What adoption mode has to mean
For the proposal to hold, *adopted* must be a **state the mesh records per node**, not an
intention, and each thing the mesh would otherwise take must say what it does in that state:
- **What is found is kept until its module is taken.** *Found* is precise: present at a declared
path or name with no record in the host's store. A found file or container is held, its original
recorded, until the operator **takes** the module on that node — the cutover, done when the
module's data has moved. Assigning prepares; taking migrates. Without the distinction the rule
never fires: the host only ever sees what assigned modules declare.
- **The firewall found on the machine stays in force.** The mesh loads no table on an adopted node
that drops by default or holds an accept. What it needs open it declares as openings the host converges
*through the found firewall*, on the incoming and the forwarded path, marked as the mesh's and
re-checked on every reconcile so a reload or reboot does not lose them. An accept in a table of
its own would not help: the found firewall's drop would still be final. What a table of its own
*can* do is refuse, and a refusal is final too — so the mesh guards the store and the broker's
management port from everyone but the private network and the machine itself, in a table that
only refuses, ahead of the container runtime's redirect — which the found firewall does not do
and cannot undo. The bus, the registry and the hub's port stay open to
anywhere: a node enrols before it has a private-network address.
- **The foundation's ports are the node's to give** — set at genesis, checked free, and kept as
that node's settings, read everywhere they are used, so adopting the foundation as modules does
not move them back.
- **The flip is per node and previewed** from what is actually reachable — listening sockets and
published ports, not the found firewall's allow list, which does not see what a container runtime
forwards.
## Options weighed
| Option | Verdict |
|---|---|
| Cut the machine over in one go: stop the predecessor's store, broker, registry and proxy, raise the foundation in their place | Rejected. Every predecessor service goes down until it has migrated, and the predecessor's other machines lose their broker. The rollback is restarting the predecessor, which is a recovery, not a step. |
| A separate machine as the control-node | Rejected. Contradicts the decision that the control-node is the machine that already carries the predecessor's broker and pipeline. |
| Converge on joining, as the mesh does today | Rejected. The base ruleset closes every predecessor port at genesis, and found files are replaced before their services move. |
| Make only the foundation's ports configurable and otherwise converge | Rejected as insufficient. It solves the bind collisions and none of the firewall or file ones. |
| Treat assigning a module as migrating it | Rejected on review. The rule that keeps found files would never fire — the host only sees what assigned modules declare — and an assignment on an adopted node would be an outage rather than a preparation. |
| **Adoption as a mode, per node, ended by an explicit flip** | The proposal. It is the conflict rule already decided here, given a duration. |
## What this leaves open
Answered by the decision record this feeds — [ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)
— only for the migration's needs. The general questions above stay open: whether a module can say
which settings are load-bearing, what shape a briefing takes, how a flagged conflict is reconciled.
One is newly sharp: the mesh opening ports through a firewall it did not install needs to speak that
firewall. There is one kind on the machines measured; a machine with another is not covered until
someone writes for it.
@@ -0,0 +1,54 @@
---
status: active
initiated: 2026-09-23
touches:
- 02-DECISIONS/0075-two-stores-and-which-provides-what.md
- 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
- 02-DECISIONS/0071-genesis-builds-from-a-mesh-that-already-exists.md
- 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md
- 04-ISSUES/085-the-packages-port-given-at-genesis-is-not-a-setting/00-report.md
- 04-ISSUES/090-the-forge-module-does-not-take-over-the-forge-genesis-raised/00-report.md
---
# 013 — The forge and the registries: what a seat is for, and what it is not
**The question, asked during the first migration:** the mesh runs a forge that serves git and
packages, an image registry, and a bootstrap forge genesis raises before any module exists. Should
there be a mesh-scoped **git seat**, the way there is one for the store and the broker?
**The short answer: no, and the question points at a different hole.** A seat answers *is there
exactly one of you*. Every symptom around the forge is about **an address and a credential nobody
resolves**, which a seat does not answer. The mechanism that would answer it — a provision, with
`serves` and a grant — already exists, is already used for the image store, and is already
declared for packages. It simply has no consumer: the one module that needs it carries a literal
instead.
See [the survey](the-survey.md) for what the code does today, with evidence.
## What was found
- **A seat does exactly two things**: it refuses a second claimant at resolution, and in one place
it answers *where is the broker*. It is a resolution-time predicate; no host ever hears of it.
- **Four mesh-scoped seats exist**, all named after a singular server the mesh runs **for its own
working** — controller, store, broker, catalogue. A forge is an application the world made, not
part of that set.
- **Nothing in the mesh requires git.** There is no provision for it, and the forge's git service —
over https and ssh — is declared nowhere. Only its npm half is a provision.
- **`package-registry` is a fully built provision with zero consumers.** The builder, its only
real consumer, bypasses it with a file naming the module, the address `127.0.0.1` and the port.
- **The builder is told where source lives, per build, by a person.** The clone URL is an opaque
string; each module remembers its own; no credential is ever attached; nothing polls a forge.
- **A seat would forbid something the mesh should allow**: a second forge — one for the mesh's own
source, one for something else — which ADR 0075 already argues for against a node-scoped claim.
## What this leaves open
- Should the forge's git service become a **provision** (`source-forge`), so the builder resolves
the address and a credential the way it already resolves the image store? That is what would let
a forge move, be renamed, or be replaced without editing every module's stored URL, and it is the
unanswered half of [issue 085](../../04-ISSUES/085-the-packages-port-given-at-genesis-is-not-a-setting/00-report.md).
- The obstacle is known and written down: such a requirement **may go unanswered during genesis**,
and the mesh has no optional requirement. Deciding that is the real work.
- Should the mesh learn when a source moves? It records the fact and never discovers it: nothing
polls, and there is no receiver for a forge's push. `build --behind` answers a question only a
person can currently make true.
@@ -0,0 +1,130 @@
# The survey — seats, the forge, the registries, as the code has them
*2026-09-23. Read from the code and the records, not from memory. Every claim here was checked
against a file.*
## What a seat is
The glossary calls a seat *a named position at a scope with a capacity*, and a claim *a module
taking a spot on it*. **Capacity does not exist in the code.** There is no capacity field and no
bench; every claim is exclusive. The "shared seat" of the design is the word `provides` wearing
that name.
Claiming does two things and no others:
- **It refuses a second claimant when a node's modules are resolved** — within one node at any
scope, and against every other node for mesh and site scope.
- **It answers one question, once:** the controller finds where the broker is by looking for the
module claiming the broker's seat.
No host is ever told about a seat. It is a resolution-time predicate.
Two mechanical asymmetries matter. A mesh-scoped seat's exclusivity is defended only by nodes
that *resolve*: a node whose modules fail to resolve contributes nothing, so a broken node does
not hold its seat against a second claimant. And a site-scoped seat does nothing at all when
either node has no site.
## The seats that exist
Twelve claims, twelve names. Four are mesh-scoped: the controller, the store, the broker, the
catalogue. All four are things the mesh runs **for its own working**, and three of them are raised
by genesis and adopted in place — which is the argument of the record that named them.
The node-scoped ones split in two: a **role on this machine** (the build machine, the packet
filter, the intrusion prevention, the private network) and **a scarce machine resource** (the
resolver's configuration file, the DNS port). The second kind is a claim for the reason a port is:
there is one of it on the machine.
Two oddities worth stating:
- The image store claims its seat at **node** scope while providing its service at **mesh** scope.
The record that discussed this left open whether it should hold that claim at all.
- **Nothing claims the two public ports.** The proxy binds them and claims nothing, which is the
hole the route handover fell into.
## The forge, and what it serves
The forge serves three things: git over https, git over ssh on its own port, and an npm registry.
**Only the npm half is in the mesh's vocabulary** — the forge provides `package-registry` and says
where it answers. Git is served and declared nowhere: no provision, no `serves`, nothing that can
require it. The only trace is the public label in its route contribution, which the mesh is
explicitly not meant to interpret.
The image store is a separate module and a separate provision, plain HTTP, trusted because it is
reachable only over the private network.
A second module also provides `package-registry`. Two providers of one provision is not a refusal
but an ambiguity, and the mesh already has the command that settles it: pin one.
## Where the builder's three addresses come from
| What it needs | How it finds it |
|---|---|
| the broker | a sealed secret of its own |
| the image store | **a real provision binding**, resolved by the mesh |
| the package registry | **a file in its own manifest**, naming the module, `127.0.0.1` and the port |
| the source repository | nothing at all — a person types a clone URL per build |
The installer says this plainly in a comment: *the one binding nothing resolves: the builder dials
the forge by a number it carries.* Genesis can override the port of that literal, and nothing
else: not the forge's identity, not its address.
`package-registry` therefore has **zero consumers**. The builder does not require it. The
provision is fully built — two providers, grants, a served description including the registry's
path — and nothing asks for it.
## How a module's source is found
It is not found; it is supplied. The clone URL arrives as an argument, travels to the build machine
and reaches `git clone` unexamined. No credential is attached, so a private forge works only if the
build machine's own git configuration already authenticates. Each module remembers the URL it was
built from, so there are as many forge addresses as modules, each frozen at whatever was typed.
At genesis there are four independent source URLs, one per flag, unrelated to each other.
If the forge moves or is renamed: every stored URL goes stale independently, each failing at its
own clone, and there is no command to re-point them. Genesis keeps working, because it takes URLs
as flags — which is the existing record's position: the forge is reached *by a name outside the
mesh*, and failover is repointing that name.
## What genesis's forge is
A bare container run before any module exists, because the toolchain resolves the mesh's own
packages by version from an npm registry. It holds **no seat, no manifest, no provision**. The
mesh is told one thing about it — a port — and only when that port is not the default. **On an
ordinary genesis the mesh never learns the bootstrap forge exists.**
Its successor module differs from it in five ways: container name, network, data directory, the
address it is told to call itself, and — contradicting a comment that says they are pinned
identically — **the image digest**. So assigning the module raises a second forge beside the first.
A seat cannot fix that. A seat is checked between manifests; the bootstrap forge has no manifest,
so no seat can see it. The check that is actually wanted — same name, same data, same network — is
between an installer constant and a manifest.
## Would a git seat be coherent?
In form, yes: add the claim and a second forge is refused. In effect it buys one refusal nobody
has hit, and forbids an arrangement the mesh should allow — a second forge for something other
than the mesh's own source.
Measured against the four mesh seats, it does not fit: those are singular servers the mesh runs
for itself, and a forge is an application. Nothing would *use* it, because the only seat consumer
answers "where is the broker", and a forge address is not one fact: a module's source is a
repository **and a path and a ref**.
## What the code already supports and nobody uses
- **`package-registry` with no consumers.** The builder's literal file is a hand-rolled copy of
the binding the mesh would have written for it.
- **Pinning a provider** already expresses *this* forge, of several, without forbidding the second.
- **Grants** on the forge are the credential mechanism that would hand the builder an account.
Today the installer creates that account directly, because nothing requires the provision and so
the grant has no consumer to write for.
## The reading
The question *should there be a git seat* is the wrong shape for the problem under it. Every
symptom — the literal address, the port that would not follow a node, the takeover that is not a
takeover — is about **an address and a credential nobody resolves**. A seat resolves neither. The
provision mechanism does, and is already built.
@@ -0,0 +1,108 @@
---
status: graduated
became: 02-DECISIONS/0106-the-bus-is-nats.md
initiated: 2026-09-23
touches:
- 02-DECISIONS/0002-nodes-communicate-over-a-broker.md
- 02-DECISIONS/0033-the-substrate-is-a-store-and-a-broker.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
- 02-DECISIONS/0041-events-are-a-relationship.md
- 02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md
- 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
---
# 014 — The bus on NATS: replace the broker, and when
**The question, asked mid-migration:** the operator wants the mesh's bus — today an AMQP broker —
replaced by NATS, with everything the broker does today. Do we finish the migration on AMQP and
move to NATS after, or go to NATS directly?
**The short answer: decide NATS now, build it in the lab in parallel, and cut the mesh's bus over
in one rehearsed rollout — after the migration's core is done and never underneath it.** "First
everything on AMQP" is already the state and costs nothing more: the predecessor's broker was
merged into the mesh's tonight, and every module converted from here on targets the sdk's broker
contract, which names no protocol. The only code that speaks AMQP is the mesh's own, in three
places, and it is swapped once.
## What was measured
**Where AMQP is spoken** (non-test files): the controller's `internal/link` (5 files, ~2.4k lines
with tests) and the builder's main; the host's `internal/link` (~1.5k); the tool runtime's
`broker-amqp.ts` (~600 with its main). **The sdk speaks none** — its `Broker` is `request`,
`handle`, `publish`, `subscribe`, `close` ([ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)
put the client in the runtime for exactly this). **Every module's tools and events go through the
sdk**; one module (`amqp-ping`, a probe) talks AMQP on purpose.
**What the bus carries:** a control queue (`control`, `.upgrades`, `.catchup`), one queue per node
(`node.<name>`), `builds`, the enrolment/report/alive/built flows, a topic exchange of events
(`mesh.events.<module>.<event>`, dead-lettered to `mesh.events.dead`), an RPC exchange (`mesh.rpc`)
and per-tool service queues (`serve.<module>.<tool>`), plus an MQTT exchange.
**What it relies on:** TLS for the bus a node enrols over; prefetch with reject/nack so the control
queue **holds messages unacknowledged while the store restarts** and retries
([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)); publisher confirms and
mandatory routing; a dead-letter exchange; **per-module accounts scoped by `emits`/`consumes`**
([ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)),
minted through the broker's management API; the broker as a module holding the `mesh-broker` seat
([ADR 0078](../../02-DECISIONS/0078-the-store-and-broker-are-modules.md)).
**The predecessor's world**, now on the same broker: ~50 AMQP connections per machine from four
machines, 595 queues, four vhosts, six users. All of it AMQP, all of it retiring module by module.
NATS speaks no AMQP: that world cannot move; it does not need to.
## How NATS answers each of those
| The mesh relies on | NATS | Note |
|---|---|---|
| topic routing keys | subjects with wildcards | same shape (`mesh.events.>`) |
| RPC and tool invocation over an exchange + reply queue | request/reply, native | simpler than today |
| competing consumers | queue groups | same |
| durable control queue, hold-unacked-and-retry, catch-up | JetStream: streams, durable consumers, ack/nak with delay, replay | the 0083 guarantee moves to JetStream; core NATS alone is at-most-once and would not do |
| dead-letter | max-deliver + advisories, or a stream fed from them | different mechanism, same effect |
| TLS bus | TLS | same |
| accounts scoped by emits/consumes, vhosts | accounts (isolation) with users and per-subject publish/subscribe permissions | stronger than today; the controller writes an auth config the host declares and the server reloads, instead of calling a management API |
| management API | `nats` CLI and an HTTP monitoring endpoint | no vhost concept — accounts instead |
| MQTT | built in | same |
| a module seat `mesh-broker` | unchanged — the seat is the server, the module changes | ADR 0079 |
| multi-node | clusters and leaf nodes | not needed now; a leaf per node is a later question |
Nothing the mesh needs is missing. The differences are in the shape of durability (JetStream must
be declared, streams and consumers are objects) and of accounts (configuration, not API calls).
## The cost, honestly
The bus is the mesh's nervous system. Moving it means: a new `nats` module in the catalogue taking
the `mesh-broker` seat; the controller's and the host's link packages rewritten; the tool runtime's
client swapped behind the unchanged contract; enrolment, reports, builds and the guard's ports
re-derived; the lab beds that prove the bus (store window, enrolment, upgrades) re-run on the new
one; and eight decisions amended or superseded. Weeks, not days — and none of it can be done
halfway on a live mesh: the controller, every host and every tool runtime move together.
## The sequencing question, answered
1. **NATS first, directly.** Stalls the migration for the length of the build; the mesh's bus
changes under a half-migrated node; the predecessor's clients still need AMQP, so a second
broker runs anyway, plus a bridge for whatever crosses. Rejected.
2. **Finish on AMQP, NATS after.** Wastes nothing — no module written from here on speaks AMQP —
but leaves the decision unmade while modules are written, and the runtimes idle. Adequate.
3. **Decide NATS now; build it in the lab in parallel; cut the mesh's bus over in one rehearsed
rollout after the core is migrated.** The predecessor's clients never notice: their broker is
the one the mesh adopted, kept as a compatibility module with an end date — the day the last
AMQP client is gone. **Recommended.**
## What this leaves open
- The **decision itself**, as a record: the bus is NATS; the AMQP broker becomes the predecessor's
compatibility broker and retires with the last AMQP client. Written when the operator says so.
- **JetStream's shape for the control plane**: one stream per concern (control, nodes, builds,
events) or one with subjects; retention; what the store window guarantee looks like as ack-wait
and nak-delay. Measured in the lab, not designed on paper.
- **Accounts as configuration**: the controller writes users and permissions into a file the host
declares, reloaded on change — which is the [ADR 0102](../../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)
discipline applied from the start — or the JWT/operator model. The first is simpler and matches
how the mesh already writes everything.
- **The MCP bridge** the operator asked for the same day is written against the sdk's contract, so
it moves with the bus and is not written twice.
- Whether a **leaf node per machine** replaces the hub-and-spoke bus later — out of scope here.
@@ -0,0 +1,142 @@
---
status: active
initiated: 2026-09-24
touches:
- 02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md
- 02-DECISIONS/0033-the-substrate-is-a-store-and-a-broker.md
- 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
- 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md
- 03-DESIGN/00-as-is/03-provisioning.md
- 03-DESIGN/01-to-be/07-the-foundation.md
- 04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md
---
# 015 — The object store after MinIO: which S3 implementation, and how the data moves
**The question.** The mesh's object store is MinIO. Its community edition is archived upstream,
its server and client images have been deleted from every public registry, and the pinned release
is four and a half years old and will never be patched
([issue 113](../../04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md)).
Which S3-compatible implementation replaces it, and what is the migration track for the data and
the provisioning model that sit on top of it?
**Why now, and why not sooner.** Nothing is on fire: nodes that already hold the images keep
running, and issue 113 establishes that the deploy path tolerates an unfetchable-but-present
image by design. The forcing function is not an outage but a one-way door — **no node that does
not already hold the images can ever provision the module again**, so the mesh's ability to stand
a node up from its declarations is already broken for this module, and silently.
**The direction is not a departure from the design; it is the design.** The foundation document
already states the commitment:
> The dependency is on the **protocol**, not the product: AMQP for the bus, S3 for the object
> store, the OCI protocol for the registry. That is what keeps the naming safe rather than a
> commitment that cannot be revisited.
The object store is also **not** a foundation service — ADR 0028 removed it, and it is an
ordinary module required through the module graph by whatever wants one. (The "exception that is
not a swap" in that passage is the relational store, whose provisioning model borrows PostgreSQL's
own meaning of databases, roles and schemas. The object store carries no such coupling: a bucket
is a bucket.) So this effort is an instantiation of an existing principle, not a redesign — which
is the cheapest kind of decision to make and the strongest kind to cite.
## What the replacement has to carry, measured
Taken from the module's manifest, its composition, its tool surface, and a search for its
consumers across the catalogue — not from assumption.
| Requirement | Evidence in the module today |
|---|---|
| S3 API | The protocol every consumer speaks; already the design's stated dependency. |
| ~~OIDC login against the mesh's identity provider~~ | **Struck 2026-09-24. Not a requirement, and it never worked.** Six variables are wired and an entrypoint blocks on the provider, which reads as a live feature. The module's own hook comment records the end state as *"policy claim missing"* — a failing login. See [01](01-candidate-comparison.md). |
| **Per-application access keys, each scoped to a bucket** | The real requirement. A "user" of the store is normally an application; the mesh already mints a credential per provisioned bucket. |
| **One live consumer using it as opaque primary storage** | A file-sync application, since early 2023: objects named by internal id, metadata in its own database. Highest-risk consumer — a live copy drifts, and its bucket name must be preserved. |
| Erasure-coded multi-node topology | Four server nodes with two data directories each, behind a load balancer. |
| A single-node form | Declared as a flavour, for development and small nodes. |
| Buckets as a typed provision | The module declares a provision type of `bucket` on a named network; the mesh mints the credential and the provider creates it (ADRs 0048, 0084). |
| A tool surface | Bucket create/list/delete, object list/info/delete, presigned URL, and provisioning. |
| A console | Published on its own subdomain through the reverse proxy, with an unlimited request-body middleware for uploads. |
**Consumers, counted:** one application module, one capture module that takes a private bucket per
node, one workflow module's tools, and the delivery/rescue internals of the shared library. The
surface is small — the cost is concentrated in the provisioning handler, the tool handlers and the
OIDC story, not spread across the catalogue.
## Candidates
**Four candidates, not three.** The comparison was briefly narrowed to SeaweedFS on the strength of
console single sign-on; that axis turned out not to be a requirement, and the incumbent's own
maintained fork had been omitted altogether. Both errors, and why they happened, are recorded in
[01 — the candidates measured](01-candidate-comparison.md), which carries the evidence and the
requirement-by-requirement detail.
In short, and only in short:
- **The maintained fork of the incumbent** — the community edition was archived and its images
deleted, but a fork publishes, tracks CVEs, and preserves the on-disk format, S3 API and
environment surface. Costs **an image reference** where every other option costs a data
migration, two rewrites and a maintenance window. Does not end the dependence on an abandoned
codebase; buys time to choose deliberately.
- **Garage** — its permission model *is* the requirement (per access key, per bucket), its admin
API is the closest match to how the mesh provisions, and the highest-risk consumer is
first-party documented against it. Remaining cost: no object versioning, no server-side
encryption or object locking, partial lifecycle — **unmeasured against the ten buckets, and the
one thing that could still disqualify it**.
- **SeaweedFS** — longest field record and erasure coding. Its console sign-on is a paid feature,
which is now beside the point. What weighs against it is narrower: its S3 surface is a gateway
translating onto its own file-system API, with no first-party support for the opaque consumer.
- **RustFS** — closest in shape to the incumbent, so the least porting. But it reached general
availability eight days before this was written, and carries an open defect in the credential
path. Two earlier claims about it are corrected in 01: it is **not** a drop-in that retains
existing data.
- **Ceph RGW** — remains rejected as disproportionate where the object store is an ordinary
module rather than a platform.
**This is now two decisions, not one:** whether to repoint to the fork or migrate, and — if
migrating — to which. Repointing does not foreclose migrating, which is the argument for taking it
first. On the corrected requirement the migration ranking is Garage, then SeaweedFS, and not yet
RustFS. Two measurements gate any graduation: **which S3 endpoints the consumers actually call**
(Garage cannot be ranked fairly until counted), and **whether the fork can read the incumbent's
on-disk format in place** — tested on a copy, because the migration between them is one-way. Both
are in [01](01-candidate-comparison.md#what-is-still-unmeasured).
## The migration track, in outline
Data movement is the easy half, and deliberately reversible.
1. **Stand the replacement up beside the incumbent**, on its own ports, its own provision type and
**its own data directory**. Nothing removed. The data directory matters: reusing one the
incumbent already holds would put a fresh single-drive store on top of a live erasure set.
2. **Copy bucket by bucket with a neutral tool.** `rclone` rather than the incumbent's own client
— the client has been withdrawn upstream too, so building the migration on it would inherit
the same dependency this effort exists to remove.
3. **Verify per bucket** — object counts and checksums, not a transfer exit code.
4. **Repoint consumers through the connection the module already publishes.** Consumers read an
API URL from the module's declared connections rather than addressing the store directly, so
the cutover surface is that value plus the provisioning and tool handlers.
5. **Freeze writes, final incremental sync, flip**, and keep the incumbent read-only as the
rollback until confidence is earned. For the opaque consumer this is **not optional and not
instant**: it stores objects by internal id with metadata in its own database, so a copy taken
while it runs will drift. It needs a maintenance window for the final sync, and the window is
proportional to 82,496 objects rather than to 230 GiB.
6. **Retire**, and only then remove the module.
The genuinely new work is not the copy. It is the **provisioning handler** and the **tool
handlers**, both written against the incumbent's admin API. *The OIDC wiring was previously listed
here and is struck: it is not a requirement and it never worked.*
## Open questions
- ~~How much of the OIDC requirement survives, and in which build?~~ **Answered, and it was the
wrong question.** The console requirement does not exist, and the login it referred to never
worked. What replaced it: which S3 endpoints consumers actually call, and whether the fork reads
the incumbent's format in place.
- Does the mesh's bucket provision translate to the candidate's identity model without weakening
what ADR 0049 says about a consumer's identity fitting the tightest backend?
- Should this effort also answer issue 113's general question — mirroring third-party images into
the mesh's own registry — or is that a separate decision? Replacing one withdrawn product with
another unmirrored upstream leaves the same one-way door in place, just further from the hinge.
- Is the four-node erasure-coded topology still warranted, or was it inherited? Worth re-asking
while the product is being chosen, rather than reproducing a shape by default.
@@ -0,0 +1,176 @@
# 015 / 01 — The candidates measured
*Rewritten 2026-09-24. An earlier version of this document ranked the candidates on whether they
preserved single-sign-on to the object store's **console**. That was the wrong axis — it is not a
requirement — and a fourth candidate was missing entirely. Both errors are recorded at the end,
because how a comparison came to be ranked on the wrong thing is worth more than the ranking was.*
## The requirement, corrected
Taken from the operator and from the running system, not from the module's shape.
**A "user" of the object store is normally an application.** The requirement is therefore
**per-application access keys, each scoped to its own bucket** — not per-human single sign-on. The
mesh already works this way: it mints a credential for every provisioned bucket, and the consumer
reads an endpoint from the module's declared connection rather than addressing the store directly.
**The console is not a requirement.** It was the axis the previous version ranked on, and it should
not have been.
**The identity-provider login never worked.** The predecessor's module wires six OIDC variables and
blocks startup until the provider answers, which reads like a working feature. It is not: the
module's own hook comment records the end state as *"policy claim missing"* — a **failing** login,
written up as progress because it proved the provider had registered. The identity provider emits no
such claim, nothing in the module creates the mapper, and the configured scope alone would not carry
a custom one. Two days of logs show no genuine login attempts, only internet scanners failing on an
STS API version. **Nothing should be carried forward on the assumption this works**, and no
candidate should be credited or penalised for matching it.
**One consumer is live, opaque, and holds real user files.** A file-sync application has used the
store as its **primary storage** since early 2023: objects named by an internal id, with all
metadata in its own database. Three consequences — a copy taken while it runs will drift, its bucket
name must be preserved or its database references break, and it is the highest-risk consumer of the
lot.
## What is actually stored, measured
| | |
|---|---|
| Logical | **230 GiB, 82,496 objects, 10 buckets** |
| Raw on disk | **468 GiB** — eight drive directories at 59 GiB each |
| Implied scheme | 468 ÷ 230 = **2.03×**, confirming erasure coding at half parity |
| Headroom | ~1.3 TiB free on the filesystem holding it |
**All eight "drives" are directories on one filesystem on one machine.** The erasure coding is
therefore not buying independent-drive redundancy; the real failure domain is the array underneath,
which has its own. This single fact decides more of the comparison than any product feature: a
scheme's redundancy model is close to irrelevant here, and what remains is its storage overhead.
At 230 GiB with 1.3 TiB free, **storage overhead is not a deciding cost either.** Replication at
three copies would run ~690 GiB against the present 468 GiB — about **+222 GiB**, comfortably
absorbed. Erasure coding at a wider stripe would *save* roughly 146 GiB. Both are rounding errors
against the headroom, and neither should decide this.
## The candidates
Four, not three. The previous version omitted the first.
### The maintained fork of the incumbent
The community edition was archived upstream and its images deleted
([issue 113](../../04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md)),
but **a fork is maintained and publishing** — `pgsty/minio`, from the Pigsty project. It restores
the console stripped from the community
build, rebuilt image and package distribution, tracks CVEs, and states that it preserves the on-disk
format, the S3 API and the environment-variable surface. Verified by pulling it: it reports a
current release, permissive-to-copyleft licensing unchanged from upstream, and identifies itself as
a community fork. Adoption is real — the server image has been pulled three quarters of a million
times.
**Why it reorders the comparison.** Every other candidate costs a data migration, a provisioning
handler rewritten against a different admin API, a tool surface ported, and a maintenance window for
the opaque consumer. The fork costs **an image reference**. It also closes the issue's one-way door:
a node holding nothing can provision the module again, and patches resume.
**What it does not do** is end the dependence on a codebase its original authors abandoned. It is
maintenance mode, largely one project's effort, with no new features intended. It buys time to
choose deliberately rather than under pressure — which is worth a great deal, and is not the same as
a decision.
### Garage
**The best fit for how the mesh provisions.** Its permission model is *per access key, per bucket,
read/write/owner* — which is the requirement above stated verbatim rather than approximated. Its
admin API is a first-class REST surface with tokens scopeable to exactly the two operations a bucket
provision performs. The opaque consumer is **first-party documented** against it, for primary
storage, including client-side encryption support.
Its previously-recorded penalties mostly dissolve under the corrected requirement: it has no console
and no identity-provider integration, neither of which is wanted; and it replaces AWS-style ACLs and
bucket policies with its own per-key-per-bucket model, which is the thing being asked for.
**What genuinely remains.** It replicates rather than erasure-codes — immaterial at this volume and
on a single array, as above. It does **not implement the full span of S3 endpoints**: object
versioning is absent, object locking and server-side encryption endpoints are absent, and lifecycle
is partial. **Whether any of the ten buckets depends on those is unmeasured, and it is the one thing
that could still disqualify it.**
### SeaweedFS
Longest field record of the group, permissive licence, erasure coding, and identity-provider
integration on the S3 API through token exchange. **Its console sign-on is a paid feature** — the
admin UI itself is open, its identity integration is not. That finding is what falsified the
previous version's narrowing, and it is now largely beside the point, since the console is not a
requirement.
What weighs against it here is narrower and more specific: its S3 surface is a **gateway
translating onto its own file-system API**, with acknowledged divergence from AWS behaviour at the
edges, and there is no first-party documentation for the opaque consumer. For a store already
holding real user files in an opaque layout, first-party support is worth more than a feature list.
It also carries more moving parts than a single-machine deployment needs.
### RustFS
Closest in shape to the incumbent — a similar admin API and client compatibility, so the existing
handlers would port with least effort — under a permissive licence, with erasure coding and a
console that does integrate an identity provider.
Two things were recorded about it earlier that were **wrong, and are corrected here**: it is *not* a
binary-level drop-in that retains existing data (API compatibility and on-disk compatibility are
separate paths, and the on-disk one is preview-scoped with documented encryption limits), and it
therefore offers no shortcut around the migration. It also carries **an open defect in the exact
area the mesh depends on** — an access key created by an identity-provider user reported denied on
all S3 operations.
Decisively for now: **it reached general availability eight days before this was written.** For a
component holding 230 GiB of real user files, field record is a feature, and it does not have one.
### Ceph RGW
Remains rejected, for the reason already recorded: disproportionate where the object store is an
ordinary module rather than a platform.
## Where this leaves it
**The decision is no longer "which product replaces the incumbent".** It is two decisions, and they
can be taken in either order but should not be confused:
1. **Repoint to the maintained fork, or migrate now?** Repointing is an image reference and it
closes the issue. Migrating now costs a data copy, two rewrites and a maintenance window, and
buys independence from an abandoned codebase sooner.
2. **If migrating, which?** On the corrected requirement the ranking is **Garage first** — its
permission model *is* the requirement, its provisioning API is the closest match, and the
highest-risk consumer is first-party supported. SeaweedFS second, on field record, with a
translation-layer caveat that matters more here than its feature list. RustFS not yet, on age.
Taking (1) does not foreclose (2), and that asymmetry is the argument for taking (1) first.
## What is still unmeasured
1. **Whether any of the ten buckets needs object versioning, server-side encryption or lifecycle.**
This gates Garage specifically and nothing else here answers it.
2. **Whether the fork's release can actually read the incumbent's on-disk format in place.** The
format is claimed compatible across a multi-year gap; the migration between them is one-way, so
this is tested on a copy or not at all.
3. **Whether the opaque consumer's maintenance window is acceptable**, and how long it actually is
at 82,496 objects.
4. **Whether the eight-drive erasure-coded shape is warranted at all.** The evidence above says it
is not buying what it appears to: eight directories, one array, one machine. It looks inherited.
**Nothing graduates to a decision before 1 and 2.**
## Two errors in the previous version of this document
Recorded because the shape of both survives anonymisation and neither is unique to this effort.
**It ranked on a requirement that did not exist.** Console single sign-on was treated as the axis
because the module's configuration showed it wired up, and a wired-up configuration was read as a
used feature. It was neither used nor working. *A configured feature is not an observed one*, and
the evidence needed was the operator's answer and the logs — both cheap, neither consulted before
the ranking was written.
**It omitted the incumbent's own fork.** The whole effort began because an upstream withdrew its
images; whether anyone had continued that upstream was the first question to ask and it was not
asked. The candidate list was assembled from a search for *alternatives*, which by construction
returns things that are not the incumbent. *When a dependency dies, "who took it over" precedes
"what replaces it".*
@@ -0,0 +1,57 @@
---
status: graduated
became:
- 02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
initiated: 2026-09-26
touches:
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
- 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
- 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
- 03-DESIGN/01-to-be/13-credentials-and-their-rotation.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- 04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md
---
# 016 — How a credential can be rotated
**What.** Which rotation mechanisms the mesh's providers can actually support, measured against
their code rather than assumed. Every provider in the catalogue was read, found by listing every definition that provides something: how it names what it
makes for a consumer, what its remove destroys, whether it re-applies a password, whether its
backend can hold two secrets for one login or two logins on one resource, and how its own
administrative credential is set. The consumer side was read too: when a module reads a secret, and
what makes it read a new one.
**Why.** [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), as first drafted,
chose *overlap*: add a second login beside the first, move every reader, then remove the old one,
"through the adapter's existing create and remove", with "no consumer changes". A review showed that
claim false. In most providers the consumer's data is named after its login, and remove drops the data
with the login. Overlap as written would have deleted every consumer's database on its first
rotation. The mechanism has to be chosen on what the providers do.
**What it touches.** Rotation in 0113 and [to-be 27](../../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md),
which [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md) decided on
these findings. The identity budget in
[ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md), if a consumer
gets two logins. The rotation already implemented, which [to-be 13](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)
describes.
**Documents.**
- [01 — The providers](01-the-providers.md): the survey, one row per provider, and what it shows.
- [02 — The readers](02-the-readers.md): how a secret reaches a running process, and what already
recreates it.
- [03 — The options](03-the-options.md): each rotation mechanism against those facts, and a
recommendation.
**Finding, in one paragraph.** All nine credential providers already re-apply a consumer's password
in place on every create, and the controller's `rotate` command relies on that. It is a working
rotation with a stated window. Eight of the nine name the consumer's resource after its login, and five
destroy the consumer's data when they remove the login. The harness, keyed by login, would do the same
on any change of login. Only one backend holds two passwords on one login, and two more hold several
tokens. Eight backends can grant two logins the same rights over one resource; the ninth can give one
login a second token. So every provider can hold **two credentials** over one resource, but only after
each adapter separates *the consumer's resource* from *the credential that reaches it*. In postgres
that also means the resource belongs to a role no login owns. Administrative credentials are a
different case. They have one party and a fixed name, and five backends take them only at first
initialisation, so changing one needs the old and the new value at once.
@@ -0,0 +1,103 @@
# 01 — The providers
Read from the catalogue's main branch: each provider's provisioner adapter (`create`, `remove`),
the client functions they call, and each definition's own credentials. The providers were found by
listing every definition that provides something and has a provisioner, not from memory. A first pass
of this survey worked from memory and missed one, mailu.
**The provisioner harness** in `mesh-sdk` calls `create` for a consumer when its contribution appears
or changes (its login, password or values), and after the provisioner restarts. It calls `remove` for
a login it applied earlier in the same process that is no longer contributed. Its record of what was
applied is kept in memory and keyed by login. Two things follow:
- a consumer whose derived login changes is removed under the old login and created under the new one,
in one pass;
- a contribution that disappears while the provisioner is down is never removed, and is left behind.
## The credential providers
`login` is the consumer's derived identity, which the adapter receives as `as`
([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)).
| provider | the consumer's resource is named | remove destroys | create re-applies the password | two secrets on one login | two logins on one resource |
|---|---|---|---|---|---|
| postgres | a database named `login`, owned by the role `login` | the database and the role | yes, `ALTER ROLE … PASSWORD` when the role exists | no: a role has one password | yes, but only through a role that cannot log in owning the database, with each login working as it. Otherwise whatever one login creates is its own, and dropping that login means handing its objects over first. Not done today |
| mssql | a database named `login`, with the login mapped into it | the database and the login | yes, `ALTER LOGIN … WITH PASSWORD` | no: a login has one password | yes, two logins mapped to users in `db_owner`. A user owning a schema cannot be dropped, and a login with an open session cannot. Not done today |
| mongodb | a database named `login`, with a user holding `dbOwner` | the database and the user | yes, `updateUser` with the new password | no: a user has one credential | yes, two users with `dbOwner` on one database. Not done today |
| redis | the key prefix `login:` on an ACL user named `login` | the user, **not** its keys | yes: `ACL SETUSER … reset … >password` replaces all of them | **yes**: an ACL user holds several passwords, added with `>` and removed with `<`. Today's `reset` discards all but the new one | yes, two users on one key prefix, once the prefix is not the login |
| minio | a bucket derived from `login`, and a service account whose access key is `login` | the access key; the bucket **only if empty**. A bucket holding objects is left, and the failure logged | yes, by removing the access key and adding it again, which leaves a moment with no key | no, but an access key *is* the login: a second key is a second login | yes, two service accounts with one bucket policy. The access key is capped at 20 characters |
| lavinmq | a virtual host named `login`, and a user named `login` with permissions on it | the virtual host, with any queued messages, and the user | yes, the user is written again with the password | no: a user has one password | yes, permissions for two users on one virtual host |
| mosquitto | a client named `login`, with a role named for it on the topic prefix `login/#` | the client and its role | yes, the password is set when the client exists | no: a client has one password | yes, two clients holding one role, once the prefix is not the login. The MQTT client identifier is chosen by the consumer, not tied to the login; a duplicate one takes the older session over |
| mailu | a mailbox `login@domain`, unless the consumer contributes its own account name | the mailbox with its mail, for a login-named one; a contributed name is left for an operator | yes, the password is set when the user exists | no for the password; a user can hold several authentication tokens, per the backend's documentation | **no**: a mail user *is* its mailbox |
| gitea (npm) | a user named `login` on a team of an organisation that owns every package | the user; **packages survive**, because the organisation owns them | yes, the user's password is set on every run | no for the password; a user can hold several access tokens | yes, trivially: a second member of the same team |
## The other providers
| provider | answers with | credential |
|---|---|---|
| umami | a website, found by its public name | none. The site id it makes has no way back to the consumer today |
| cloudflare-dns | a public name derived from `login` | none handed to the consumer; its own API token is an operator value |
| showcase | a route | none |
| mesh-vault | custody: it records and withdraws sealed values in a ledger | it holds secrets; it makes none today |
verdaccio provides the npm registry too, and has no provisioner.
## Each provider's own administrative credential
| provider | identity | how the backend takes it |
|---|---|---|
| postgres | a fixed superuser | from a file **only at first initialisation** |
| mssql | `sa` | from the environment at first setup. The image documents no file form, and the definition records that as a declared exception |
| mongodb | a fixed `root` | from a file **only at first initialisation**, when the data directory is empty |
| mosquitto | a fixed admin client | seeded into the broker's dynamic-security file **once**; the seeding step skips when the file exists |
| lavinmq | a fixed admin name | per its own bootstrap code, **only on a first boot** with an empty data directory. No resource in the definition runs that bootstrap; what sets it on a running mesh is outside the catalogue |
| redis | the default user | from `requirepass` in a configuration the mesh renders, read when the server starts |
| minio | a fixed root user | from a file, read when the server starts |
**In five of seven, a new administrative value takes effect only through a command run with the old
one.** The credential file is mounted directly into both the server and the provisioner. So replacing
it recreates the provisioner, which then holds only the new value while the backend still expects the
old one, and the provisioner is locked out. That is worse than changing nothing.
Every provider module also has its own bus account, an own secret, read at start.
## What the tables show
1. **Every credential provider already rotates in place.** All nine re-apply the password on the
same login each time `create` runs. The controller's `rotate` command relies on that: it replaces
the credential in the inventory and sends both ends in one push. Its own comments state the window,
between the provider applying and the consumer restarting, in which the consumer cannot
authenticate.
2. **Eight of nine name the consumer's resource after its login.** Only gitea separates them,
because an organisation owns the packages. A second login therefore has no resource of its own to
reach, and cannot share the first one's without the adapter granting it.
3. **Five of nine destroy the consumer's data when they remove the login**: postgres, mssql and
mongodb drop the database, lavinmq drops the virtual host with its queued messages, and mailu
deletes the mailbox with its mail. minio drops only an empty bucket, and redis leaves the keys. In
those five, *retire a login* and *delete the consumer's data* are one call. With the harness keyed
by login, a changed login triggers it too.
4. **One backend holds two passwords on one login** (redis). Two hold several tokens beside one
password (gitea and mailu). A rotation built on two secrets per login would work for three
providers out of nine.
5. **Eight of nine can give two logins the same rights over one resource.** Group roles in postgres,
database roles in mssql and mongodb, permissions in lavinmq, a shared role in mosquitto, a shared
policy in minio, a shared key prefix in redis, a shared team in gitea. mailu cannot, because its
user is its mailbox, but it can give one user a second token. So every provider can hold **two
credentials** over one resource, though not every one as two logins. No adapter does either today.
6. **Ownership is a trap in two backends.** In postgres whatever a login creates is that login's, so a
second login cannot alter the first one's tables, and the first cannot be dropped while it owns
them. The one-step way out deletes them. In mssql, a login cannot be dropped with a session open,
nor its user while it owns a schema.
7. **The administrative credentials have one party and a fixed name**, and five backends take them
only at first initialisation. The provisioner needs the old and the new value at once to change
them. Today nothing can give it both.
8. **A consumer's identity is already the resource's name.** The login is derived from the
assignment, which is a module on a node, so the current login and "the consumer" are the same
string today. A second login would need a new name. The resource can keep the one it has.
## Seen on the way
The redis configuration names no ACL file, so a consumer's ACL user exists only in memory. A restart
of the redis server erases every consumer's user. The provisioner does not create them again until it
restarts itself, because its in-memory record says they are done. That is not a rotation finding, but
it is a live fault, and it is recorded here so it is not lost.
@@ -0,0 +1,46 @@
# 02 — The readers
How a secret reaches a running process, and what makes the process take a new one.
## No module watches a secret
A search of every module's code in the catalogue found no file watching of any kind, and no
re-reading of a secret while running. **Every reader reads a secret when it starts.** There is no
consumer that takes a new value live, so every rotation that changes what a consumer presents ends in
the consumer restarting.
## The host already recreates what read a changed file
The node host records, for every long-running container, the digest of each file it read when it
was created: its env-files, and every file bind-mounted into it directly. When a digest changes, the
host recreates the container, even though its spec is otherwise unchanged. This is the fix for
[issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md).
It is on the host's main branch, while the issue is still recorded as located, not fixed.
Two cases are deliberately left out and need `restart-on` in the definition:
- a file read out of a **mounted directory**, because the host cannot know whether the service reads
it once or watches it (a route proxy re-reads its routes live; a provisioner polls what it receives);
- a **process** rather than a container.
## What the catalogue does with it
22 definitions declare a secret they receive. In 16 of them it reaches the service through a
rendered file, usually an env-file. That case the host already covers. 14 declare `restart-on` for
something. Whether each of the 22 is fully covered depends on how its secret travels: through an
env-file or a direct mount, which the host covers, or through a directory or into a process, which
needs `restart-on`. **That was not classified module by module.** It is the check to run before a
rotation mechanism relies on it.
The count covers only the `secrets` field. The 49 modules with their own bus account, and 54 with any
own secret, are readers too, and their bus accounts are rotated like any credential two parties hold.
Their files are mounted directly, which the host covers, but the classification has to name them.
## What this means for rotation
- The *read at start* half of 0113's recipient model is already true, and mostly already handled by
the host. The restart is derived from the files a container reads, not declared per secret.
- Any mechanism, in place or overlapping, ends with the reader being recreated. What differs is
whether the credential it held until then still works.
- For a single-party secret, a module's own, the reader is also the only holder. There is nobody to
overlap with, and delivering the new file recreates the reader.
@@ -0,0 +1,79 @@
# 03 — The options
Three mechanisms, weighed against [01](01-the-providers.md) and [02](02-the-readers.md).
## A. In place, as today
The vault makes a new value. Every applier re-applies it on the same login, which all nine
providers already do. Every reader is recreated by the host.
- **Works with:** every provider, unchanged. It is what `rotate` does now.
- **Costs:** a window per consumer, from the provider applying to the consumer being recreated. They
are on different machines, and nothing orders them. A reader whose machine is unreachable from the
mesh but still reaches its provider stays locked out until the mesh reaches it again.
- **Admin credentials:** the natural form. The provider module is the only party, and it has to
apply the new value with the old one anyway (finding 6).
## B. Two secrets on one login
The applier adds the new password beside the old one, readers move, and the old one is removed.
- **Works with:** redis natively, and gitea and mailu through tokens. **Not** with the other six, whose
backends hold one password per login (finding 4).
- **Verdict:** not a mechanism, a special case. Using it where it exists and something else
elsewhere is the "this way or that way" the design is trying to remove.
## C. Two credentials per consumer, over one resource
The consumer has two credentials and uses one at a time. The applier ensures the other with the new
value and gives it the same rights over the consumer's resource. Readers move to it, and then the old
credential is retired, which removes the credential only, never the resource. **What a credential is,
is the adapter's**: a second login for eight providers (finding 5), a second token on the same login
for mailu. The mesh sees one mechanism.
- **Works with:** every provider, **after** each adapter changes:
- the resource is named after the consumer, not the login. Today the two are the same string (finding
8), so existing resources keep their names, and the current login stays one of the two;
- the resource is owned by the resource, not by a login. In postgres that is a role no one logs in
as, which each login works as, and ownership of an existing database moves to it once (finding 6);
- both credentials get the same rights, over data and structure;
- *retire a credential* and *remove the consumer* become two operations. Today they are one call, and
in five providers that call destroys data (finding 3). The harness must key by consumer, so that a
changed login is not a removal. This is the whole of the danger, and it has to be split, whatever
else is chosen.
- **Costs:**
- every credential adapter changes;
- the harness learns the alternation and a confirmation per step, and rotation state has to live
somewhere that survives a restart, which the harness's memory does not;
- the second login's name must fit the tightest backend. That is 20 characters for a minio access
key ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)), and
a suffix spends part of it;
- retiring an mssql login has to end its sessions first.
- **Gains:** no window. A reader that cannot be reached keeps a working credential until it can.
- **Does not apply to** single-party secrets: admin credentials and a module's own secrets. There is
no second party to overlap with.
## Independent of the choice
- **Split remove, and key the harness by consumer.** Retiring a credential, or a login changing, must
never be able to destroy a consumer's data. That holds under A too, because A's remove is the same
call.
- **Admin credentials are applied by their own provider**, using the old value, with the new one staged
beside it. Five backends take the value only at first initialisation. Replacing the file first locks
the provisioner out (finding 7).
- **Classify the readers** ([02](02-the-readers.md)), bus-account readers included, before relying on
derived restarts.
## Recommendation
- **Two-party credentials, consumer credentials and bus accounts: C**, because it is the only
mechanism every provider supports, and it closes the window instead of shortening it. Its prerequisite, separating the resource from the login and
retiring a login from removing a consumer, is worth doing on its own, because it removes a
data-loss path that exists today.
- **Single-party secrets (admin credentials, a module's own): A, staged.** In place, applied by the
provider that holds them, with the new value beside the old until it has taken.
- **Until the adapters are changed, A stays** as `rotate` implements it, with its window stated. It is
not replaced by a mechanism the providers cannot yet carry.
This is two mechanisms, split by a property of the secret rather than by provider: whether it has one
party or two. Every provider is treated the same way for the same kind of secret.
@@ -0,0 +1,47 @@
---
status: active
initiated: 2026-09-26
touches:
- 00-META/mission.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0010-delivery.md
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
- 03-DESIGN/01-to-be/06-the-controller.md
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
- 03-DESIGN/00-as-is/09-interfaces-and-observability.md
---
# 017 — A mesh that heals itself
**What.** The behaviour the operator wants: a mesh that runs itself. It notices what is wrong,
repairs what it can, and hands what it cannot repair to someone who can, with the reason. This effort
writes that wish down as intended behaviour, designed for the bus the mesh is moving to
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md): NATS). It also records what can be done
pragmatically before that move.
**Why.** The mission is *a mesh that controls itself* ([mission](../../00-META/mission.md)). The
mesh can tell whether it is up. It cannot tell whether it is right. The as-is page on observability says so
([as-is 09](../../03-DESIGN/00-as-is/09-interfaces-and-observability.md)). To-be 06 names an
`observability` context in the controller and leaves its store undecided. Nothing routes a condition
the mesh cannot fix to anyone. The cost is measurable: **46 of the 116 issue reports in this
repository describe a failure that was silent.** A mesh that heals itself is, first, a mesh that stops
failing silently.
**What it touches.** The controller's observability context, the node lifecycle's liveness, delivery
([ADR 0010](../../02-DECISIONS/0010-delivery.md), [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)),
the provisioner harness, and rotation, which is proposed alongside to-be 27 as ADR 0114.
**Documents.**
- [01 — The intended behaviour](01-the-intended-behaviour.md): the wish, as principles and as how
the mesh behaves once the bus is NATS.
- [02 — Now, pragmatically](02-now-pragmatically.md): what is done before NATS, why it does not
build anything the move would throw away, and what has been done already.
**Next.** Two measurements this effort owes before it can graduate:
1. **Every loop in the mesh**: what it converges, and whether it compares against observed state or
against its own memory. Issue 120 found the provisioner harness trusting memory. The same pattern is
expected elsewhere.
2. **The 46 silent failures, classified**: a missing observation, a loop trusting memory, or a missing
escalation. That shows which mechanism removes the most of them.
@@ -0,0 +1,85 @@
# 01 — The intended behaviour
The operator's wish, written as behaviour: what a person or an agent sees the mesh doing. This is a
target to design toward, not a design. Every part of it is to be decided through a record before it
is built.
## Principles
**1. Every loop compares what should be with what is, never with what it did.** Desired state is the
mesh's: assignments, requirements, seats. Observed state is read from the thing itself: the container,
the backend, the node. A loop that compares against its own memory of what it applied is blind to
anything that changed behind its back. That is issue 120, and it is the pattern this whole effort is
written against.
**2. Healing is the ordinary path run again, never a second path.** Repairing a lost login is
provisioning it. Repairing a dead container is converging the node. Repairing a stale declaration is
delivering it. A repair that needs its own code is a second way of doing something, which is exactly
what the mesh is removing everywhere else.
**3. A repair never destroys.** Healing may recreate, re-provision, re-deliver and restart. It may
never delete a consumer's data, retire a credential someone still uses, or pick a winner between two
contradictory states. Where the only repair is destructive, it is escalated.
**4. Nothing fails silently.** Every condition the mesh cannot repair within its budget becomes
visible. It is named, it says since when, why, and who can resolve it. It is visible until it is
resolved, and resolved by observation, not by someone clicking it away.
**5. What the mesh cannot fix goes to an agent.** Per the mission, an agent may be human or not. A
condition that needs judgement is handed to one, as work, with what the mesh knows. It is not handed
over as a notification that someone may or may not read.
**6. Correctness, not only liveness.** A running process that authenticates with a dead credential,
serves an old version, or routes nowhere is not healthy. What a provision's contract promises is what
is checked: the credential authenticates, the route answers, the version is the declared one.
## The loop, everywhere
Every part of the mesh that owns something runs the same loop:
1. **know** what should be true: from assignments, requirements and seats;
2. **observe** what is true: from the thing itself, on its own cadence;
3. **repair** the difference by running the ordinary path again, within a budget of attempts and
time;
4. **raise** a *condition* when the budget is spent or the only repair is destructive;
5. **clear** the condition when observation shows it resolved.
A **condition** is a durable fact about something the mesh owns, such as a node, an assignment, a
provision, a seat or a rotation: what is wrong, since when, the evidence, what was tried, and who can
resolve it. Conditions are the one thing a person or an agent looks at to know whether the mesh is
right. `status` is the list of open conditions. When it is empty, the mesh is right, not just up.
## On NATS
[ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md) moves the bus to NATS, and NATS makes most of
this cheaper, because observation becomes something every component publishes rather than something
a central process polls.
| the wish needs | on NATS |
|---|---|
| every component says it is alive | a heartbeat on a subject per node and assignment; silence past its interval is a condition, and nobody polls |
| every component says what it observed | observations published on subjects (`mesh.observed.<node>.<assignment>`, for instance), consumed by whoever owns the comparison |
| the last known state survives restarts | a JetStream key-value bucket of observed state per owner; the provisioner's "what I applied" and a rotation's step live there, not in memory |
| conditions are durable and watchable | conditions as entries in a key-value bucket, watched by anyone who cares: a surface, an agent, the controller |
| the bus itself is observed | the server's advisories (a consumer exceeding its deliveries, a slow consumer, a client disconnecting) and its monitoring endpoint become observations like any other |
| a repair is retried, not lost | JetStream redelivery with delay, which is the same mechanism [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)'s guarantee moves to |
| work handed to an agent | a condition that needs judgement published as a task on a subject an agent's queue group consumes |
**Who compares.** Each owner compares its own: the host for its node's containers and files, a
provisioner for its backend, the vault for rotations, the controller for delivery and seats. The
controller's observability context does not repair anything. It holds conditions, their history,
and the view across the mesh. It notices what no owner can see about itself: an owner gone silent.
## What stays human
Some repairs need the operator's key, and the mesh says so rather than pretending otherwise:
re-raising the vault or the broker, and recovering a node's identity. These are conditions too, with
the procedure named, and they are the only ones that can never clear themselves.
## Open
- The budgets: how many attempts, over how long, per kind of repair.
- How a condition that needs judgement reaches an agent, and how the agent's action is recorded.
- Where the observability context stores history (to-be 06 left it open; volume argues against the
relational store).
- Which correctness probe each provision's contract offers, and how often it runs.
@@ -0,0 +1,46 @@
# 02 — Now, pragmatically
The intended behaviour lands on NATS. The bus moves after the migration's core
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md)). Until then, work toward it is chosen by one
test:
**Does it survive the move?** A change to what a loop compares against, or to what an adapter can
tell about its backend, survives, because it is independent of the bus. A new AMQP queue for health
reports, a poller written against the broker's management API, or a condition store built on the
current broker does not survive, and is not built.
## Done
**The provisioner asks the backend, not memory** (issue 120). The SDK's harness gained an optional
`holds` on the adapter, asked for every applied consumer every minute. A consumer the backend no
longer holds is provisioned again. Being unable to ask is not treated as loss. The cache module
implements it first, because its server keeps its users in memory and forgets them all on a restart.
That was verified against a real server: a restart erases every consumer's user, and `holds` answers
correctly for absent, present, wrong-password, disabled and deleted.
Changes: mesh-sdk PR #7 (0.1.1) and mesh-catalog PR #84.
This is principle 1 applied to one loop. It survives the move unchanged. On NATS, the harness's
record of what it applied moves from memory into a key-value bucket, and `holds` stays as it is.
## Next, in order of silent failures removed
1. **`holds` for the other credential providers.** Each backend can answer whether a login exists
with the mesh's password without changing anything. Where a backend cannot check a password without
logging in, logging in is the check.
2. **The harness's other blind spot.** A consumer that goes away while its provisioner is down is never
removed. The fix is the same principle in reverse: list what the backend holds, and compare it with
what the mesh asks for. Removal stays subject to ADR 0114's rule that it never follows from a login
changing.
3. **`status` reports what owners already know.** The host knows which containers it recreated and
why. A rotation knows who it waits on. Delivery knows what is outstanding. Surfacing those as
conditions in the existing `status` needs no new transport. It is the shape the NATS condition store
will hold.
4. **The loop inventory and the classification** in [00](00-overview.md). They decide what comes after
these three.
## Not now
- Heartbeats, observation subjects, key-value state, advisories: all NATS, all after the move.
- Handing conditions to agents: designed with NATS, where a task on a subject is native.
- Choosing the observability store: decided when there is something to store, which is after the
move.
@@ -0,0 +1,86 @@
---
status: graduated
initiated: 2026-10-02
touches:
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
- 02-DECISIONS/0161-what-deserves-a-seat.md
- 03-DESIGN/01-to-be/05-the-node-host.md
- 03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md
- 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md
- 03-DESIGN/01-to-be/34-the-console.md
- 03-DESIGN/00-as-is/10-module-catalogue.md
- 04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 018 — The operator's machine as modules
**What.** The mesh owns the whole machine, not only the services on it. Everything a person
configures on a node — the login manager, the display server, the window manager, the shell, the
terminal, the launcher, the notifier, the audio setup, the boot images, the downloads folder, the
agent at the terminal — is a module: a package, the files it owns under `/etc` and under the
operator's home, the seat it holds, the tools it serves. One default configuration per module,
varied per node only through settings rendered into the file or a kept operator region, never
through an edit. The servers take the universal modules (shell, prompt, git, the agent); the
workstations take those and the graphical stack, which a capability the machine reports gates.
This effort writes that behaviour down, measures what the predecessor's desktop modules actually
contain, and settles what the mesh must gain before the first of them can be written.
**Why.** The predecessor is retired on every node. What it still owned on the two workstations —
about thirty modules' worth of dotfiles, user units and `/etc` files — is now owned by nothing:
no generator regenerates them, and a fix to one of them is a hand edit that nothing records. The
migration scoped these modules out as *the workstation's own environment*, and
[to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) names them as the last
thing the predecessor was keeping alive. To-be 29 covers one directory, `~/.ssh`, and draws a
boundary inside it. The operator wants no boundary: the machine is the mesh's, as far as it makes
sense to configure it. That is a wider scope than any design states, and it reaches three records
that were written for services: what a module is, where a module's tools run, and what a managed
file may be.
**What it touches.** The module definition ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)),
seats and their contracts ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
where a module's tools run ([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md),
[to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6), the host's vocabulary
([to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md)), managed files and settings
([ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md),
[issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)),
and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalogue.md)).
**Documents.**
- [01 — The intended behaviour](01-the-intended-behaviour.md): the operator's wish, written as
how the mesh behaves, in the mesh's own words.
- [02 — What exists, and what is missing](02-what-exists-and-what-is-missing.md): the
predecessor's desktop modules measured; which records already say what is wanted; the gaps.
- [03 — One tool executor per node](03-one-tool-executor-per-node.md): where a module's tools
run. The direction the operator set, the evidence for it, and what it supersedes.
- [04 — The seats of the environment](04-the-seats-of-the-environment.md): the roles a machine
has once, their candidate contracts, and what gates each.
**What it had to settle, and where each landed.** *(Graduated 2026-10-02.)*
1. A module is one *managed thing*, software or not, and every module may serve tools — or ADR
0040 already says this and only its examples are narrow.
2. One tool executor per node, host-side, module-agnostic; which records it supersedes and
in what form the console continues.
3. Per-node variation is a setting rendered into the file or a kept region, never an edit —
ADR 0011 stands — and issue 168 is fixed before any environment module carries a setting.
4. User-scoped units on the host's `service` shape, and a service-manager seat whose holder
serves the tools about them.
5. The operator account stated on every node; today no node record carries one.
6. The seats of the environment and their verbs, one record per seat, slowly, because a
seat's tools bind every future holder.
7. Where the environment modules live: this catalogue, or one of their own as the media chain
has; and whether a third-party organisation's tooling belongs in a public catalogue at all.
@@ -0,0 +1,99 @@
# 01 — The intended behaviour
*Written 2026-10-02 from the operator's words, in the mesh's words. What is wanted, before what
exists. Where a sentence restates a record, the record is named; where it goes further, that is
said.*
## The machine is the mesh's
**Everything configurable on a node is declared by a module.** Not only the services the mesh
runs: the login manager, the display server, the window manager, the bar, the launcher, the
notifier, the compositor, the lock screen, the terminal emulator, the clipboard, the shell and its
prompt, the editor, the audio setup, the boot images, the package manager's configuration, the
agent a person runs at a terminal, and the folders a person works in — a downloads folder that is
tidied, backed up, distributed to other nodes and asked questions of. System folders and the
operator's home alike. The operator is the only person on every node, so the mesh manages the
person's machine, not a machine with a person on it.
This is [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)'s definition applied without
the service bias its examples carry. A module is one managed thing, named once, described
completely by its manifest. It may have a package, files, a container, a unit, a binary, a seat it
holds, and tools it serves — any one of these, or all, or two. There is **no kind of module**: zsh
has a package, files, a seat claim and the tools that claim obliges it to serve; downloads has a
folder, a process and tools; nftables has a package, files, a service, a seat and tools. The
difference is what each declares, not what each is.
**The home has no boundary.** [To-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
owns one directory under the home and draws a line inside it between the mesh's and the person's.
Here the line is drawn only by what the modules declare: every file some module places is the
mesh's; what no module declares is found and left alone, exactly as the adoption rules already
say for a machine. The reach is bounded by sense, not by a rule — the mesh configures what can
be configured, and a person's documents, projects and history are data under
[ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), not configuration.
**A module names no node and no path.** The operator account is a node fact and the home is
derived from it ([to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2,
shipped in the controller; its record is proposed in an open change). A module places a file
*under the home, owned by the account*, and the same manifest lands on a server and a laptop.
## One default, varied by settings, never by edits
**One module, one default configuration.** The window manager module ships the configuration
that is right for every node. There are no flavors: the predecessor's one desktop module carried
four, one per class of machine, and what differed between them is what settings are for.
**A node varies a module in exactly two ways.** A **setting**, declared by the module with its
type, meaning and default (proposed alongside the container-runtime records), set for the mesh
or for one node, and rendered into the file at composition — the value is in the file, not in an
environment variable the file reads. Or a **kept region**: a block in a file the mesh writes
*into*, where the operator's own lines survive every push
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). An
edit to a managed file outside such a region is not a third way; it is overwritten, as
[ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) says, and the
predecessor's habit of adopting disk drift back into its database is not carried over.
The predecessor's theming — some ninety environment variables substituted into templates at sync
time, with tools to list and set them — is the same idea with the wrong rendering. The knobs
become declared settings; the file carries the value.
## Roles a machine has once are seats, and seats carry tools
**A role a machine fills at most once is a node-scoped seat**, declared by a module
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): the login shell, the
display session, the display server, the terminal emulator, the launcher, the notifier, the
compositor, the lock screen, the service manager, the boot loader. Several modules may be able to
hold one — zsh, fish and bash can all hold the login shell — and the assignment on each node says
which does. Installing a shell is installing software; holding the seat is being *the* shell.
**A seat's contract is its tools** ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
Every holder of the login-shell seat serves `execute`, which takes one string, the command, and
runs it on the node the seat is scoped to. Every holder of the boot seat serves "rebuild the boot
images", so *"rebuild your boot images"* is a verb addressed to a machine, not a one-off step in
a hook. Every holder of the service-manager seat answers for the units on the machine, system and
user scope. A module may serve its own tools beside the seat's
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §2): show the rendered
configuration, set a theme value, report status.
**Any tool may be called from any node.** The operator's statement, and the grant model it
implies: the executor on each node may call everything, as the console already may. A verb that
needs root on the machine is the module's concern — the tool escalates, the executor and the
caller do not know.
## Servers and workstations differ by capability, not by catalogue
The same catalogue serves every node. A module declares what it needs — a graphical session, a
display server, a container runtime — and the machine reports what it has, as the profile already
reports eight capabilities today ([issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)).
Assignment refuses the wrong placement by name
([ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3). So every node takes the shell,
the prompt, git and the agent; only a node with a graphical session can take the display server,
and only a node holding the display server can take a window manager. Nothing in a module says
"workstation".
## What the operator would say to the mesh
*Set the login shell on the build node to fish. Rebuild the laptop's boot images. Show me the
window manager's effective configuration on the desktop and where each value comes from. Give
the downloads folder on the laptop to the home server. Run `uptime` on every node.* Each of these
is a seat verb or a module tool, addressed to a node, answered by whatever holds the role there.
@@ -0,0 +1,91 @@
# 02 — What exists, and what is missing
*Measured 2026-10-02 on one installation: two workstations, two servers, all four converged to
the mesh; the predecessor retired on the last workstation the day before. Numbers are from the
machines and the repositories, not from memory.*
## 1. What the predecessor's desktop looks like
The predecessor's catalogue on the laptop held **34 modules**, of which **28** are the operator's
environment rather than services. By what they declare:
| shape | count | examples |
|---|---|---|
| package only | 9 | browser, mail client, process monitor, media player, file manager, chat |
| package + `/etc` files + system service | 5 | login manager, display server, power and thermal daemons, package manager configuration |
| package + files under the home | 6 | shell and prompt, the agent at the terminal, scripts, the sync client, a music player |
| files under the home + user units + hooks | 2 | the desktop environment, audio |
| third-party organisation tooling | 6 | out of scope here |
**The desktop module alone** declares **88 files**, **4 flavors** (the window-manager stack, and
one per class of machine), **2 user units** with a hook to enable them, 8 files under `/etc`, a
wallpaper shipped as an asset, and reads **about 90 environment variables** as theme knobs,
substituted into its templates at sync time and set through a theming tool. Its hook exists
because *shipping a unit file does not run it*: one unit had been deployed for months and ran on
one machine only, because somebody had enabled it there by hand.
**The shell module** ships `~/.zshrc`, the prompt configuration, an `~/.ssh/config` that the
predecessor generated from its registry, and a `LOGIN_SHELL` variable applied with `chsh` by a
hook. Two flavors: the prompt theme, and autocompletion.
**Other modules write into the desktop module's files.** The chat client places i3 and notifier
snippets into `config.d` directories the desktop module owns, and its launch flags, window
placement and notification colours are each a variable with a default.
**One-off steps live in hooks** across the set: enable user units, `chsh`, create a swap file,
`mkinitcpio`, enable a vendor VPN service the package ships disabled. Every one is state the
host could declare or a verb a seat could serve; none is today.
## 2. What the migration did with them
The migration's module to-do scoped the whole set out as *desktop / workstation ricing — the
workstation's own environment* and *node/OS tooling — managed on the node, never catalogue*. The
last workstation's runbook then split the same set three ways: **A**, system scope, which the
host's vocabulary can express today (the login manager, the display server, the power daemons,
the package manager, the container runtime); **B**, under a home or a user unit, waiting on
to-be 29; **C**, package only, the operator's call. The migration log closes the workstation with
*the operator's desktop awaiting its design*.
Two things followed from scoping them out. Nothing regenerates those files now, so a fix is a hand
edit — the login manager's session script was fixed this way on the day of writing, and recorded
in a repository nothing deploys from. And the one piece of this family written as a mesh module,
the ssh client, was closed on hold in the catalogue until the controller carried the account fact.
## 3. What the records already give
| wanted | record | state |
|---|---|---|
| one module per managed thing; every module may have tools | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) | accepted; examples are services, and the shell is named as a *shared* seat |
| a module declares its own node-scoped seat | [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) | accepted |
| a seat's contract is its tools; a holder may add its own | [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) | accepted; one node seat serves verbs live |
| a capability the machine reports gates a holder | [ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3 | accepted; the profile already reports `graphical-session` |
| the account as a node fact; a file under the home owned by it | [to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2 | built in the controller; its record proposed in an open change |
| inside a home: owned, written into, written by the module, found | proposed in the same change | proposed |
| a setting declared with type, meaning, default and cost | proposed with the container-runtime records | proposed |
| a managed file is derived; an edit is overwritten | [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) | accepted |
| the mesh writes into a shared file, never over it | [ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md) | accepted |
| a module names no path; the host resolves the home | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) | accepted |
| the `user` shape: a login shell is declared state | [to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md) | designed; used by no module |
## 4. What is missing
1. **The account is recorded nowhere.** The node record has the column; on all four nodes it
is empty. Every home-scoped module is unassignable until the operator states it.
2. **User-scoped units.** The host's `service` shape has no user scope. To-be 29 says it
plainly: *a workstation's per-user daemons have no form the mesh can send.* The desktop
module's two units, the audio masks, the power module's memory guard and the thermal
daemon's profile switcher all need it.
3. **One-off steps.** `mkinitcpio`, `chsh`, creating a swap file. Each is either declared
state the host lacks a shape for, or a verb a seat should serve. An action in a declaration
is refused over the link, and rightly.
4. **Settings leak** ([issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)):
a setting reaches every mergeable file and every contribution of its module. Ninety theme
knobs on that mechanism would reach ninety files. The proposed settings record says a setting
names the file it lands in; that has to ship first.
5. **Where tools run.** Every module that serves a tool today does so from its own container
per node. See [03](03-one-tool-executor-per-node.md).
6. **A seat's verbs are undecided for every seat but three.** To-be 33 leaves which verbs each
seat serves as *a decision per seat, slowly*. The environment adds a dozen seats.
7. **Catalogue placement.** The media chain left this catalogue for its own; whether the
environment does the same, and whether a third-party organisation's tooling belongs in a
public catalogue, are unasked.
@@ -0,0 +1,88 @@
# 03 — One tool executor per node
*The direction the operator set on 2026-10-02, the evidence it rests on, and what it supersedes.
A direction, not yet a decision: the record is written when this effort graduates.*
## Where tools are served today
[To-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) names three families — a
role's tools on the seat, a module's own tools on the module, the mesh's own verbs on the
controller seat — and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
says a runtime serves the subjects its membership issues. What *runs* that runtime is
[ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
one supervised process per module, under the module's own account, carrying that module's
compiled tools. Measured on the live mesh:
| who answers | how it runs | count |
|---|---|---|
| the mesh's own verbs | the controller binary, on its node | 17 verbs |
| the store seat | the store's own runtime | 2 verbs |
| the packet-filter seat | **a container per node**, built on the tool-runtime base image, with the network namespace and `NET_ADMIN`, on all four nodes | 3 verbs and 1 own tool |
| every module's own tools | the module's container, one per node it runs on | 67 tools across the catalogue |
| the console | a container per node, loopback MCP, `invokes: *` | serves none, calls all |
| the host | — | serves nothing; answers no question about the machine |
**The packet-filter holder is the case to look at.** The module is a package, three files and a
system service. To serve three verbs it also declares a built image and a container on every
node whose only job is to answer them. Scaled to the environment — a shell, a prompt, a launcher,
a notifier, a compositor, a login manager, a service manager, a boot loader, a downloads folder —
that is one container per module per node for software that is itself not a container, and the
operator's judgement is that tools should not run inside a container at all.
## The direction
**One tool executor per node, on the host side.** A process the host supervises, the way the
launcher supervises the host ([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)): not a
container, one bus credential for the node, module-agnostic. It loads the tool code of every
module assigned to the node and serves each module's tools and each held seat's verbs on the
subjects the membership issues — nothing changes in what [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
say about subjects, grants and memberships; what changes is that one process subscribes for the
node instead of one per module.
- **A module brings its tools as a built artifact**, a bundle the pipeline produces, never an
image. The executor knows bundles and subjects; it knows nothing of zsh or nftables.
- **A tool is code the module wrote**, one function behind an MCP verb. `execute` on the shell
seat is a function with a string argument. The executor does not declare, template or
interpret tools; it runs them.
- **Root is the module's concern.** A tool that must change the packet filter or rebuild boot
images escalates itself. The executor does not run as root for everyone, and the caller does
not know.
- **Any node may call any tool on any node.** The executor's credential may call everything,
as the console's already does; per-module grants on the calling side are not kept.
- **The mesh's own verbs stay with the controller** ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
and a mesh-scoped seat's verbs run on the node that holds it
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
No hub is added; the controller's node is already one.
**The console is the executor, renamed.** It already runs on every node with a credential that
may call everything, and it already serves the mesh's tools to whoever is on the machine over
MCP on loopback ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
It moves out of its container into the host's process tree, gains the serving half, and takes a
name that says what it is — *the node's tool runtime* or simply *node tools*; "console" names
the operator's half only.
## What it supersedes, and what it keeps
| record | effect |
|---|---|
| [ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md), [ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) | superseded *for tools*: one process per node runs every module's tool code, under one account. A module's long-running service — a daemon, a container — is untouched; the executor runs tools, not services. The record must say why one account for every module's tools is acceptable: every tool may be called from every node anyway, and root is taken by the tool, not granted to the process |
| [ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) | kept in substance — a module, assigned per node, loopback MCP, the machine's login is the authority — changed in form: host-side, not a container; serves as well as calls; renamed |
| [to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6, [to-be 34](../../03-DESIGN/01-to-be/34-the-console.md) | amended the same way |
| [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §3, *a container may ask for a capability* | moot for that holder: the verbs run on the host side and escalate as they need |
| the container-runtime seat, proposed in an open change: *the holder runs as a supervised process and serves the verbs locally to the host and on the bus* | consistent — a supervised process serving verbs is what the executor is; the open question is whether that holder keeps its own process or serves through the executor like everyone else |
| the tool-runtime base image | no longer the way tools reach a node; may remain the way a module's *service* is built |
## What stays open
- **The executor's language.** The host is a static Go binary and loads no plugins, so the
executor is a sibling process, and its language decides the language of every tool bundle.
One decision, taken once.
- **How a bundle reaches the node.** An artifact of the module's build, delivered as the host
delivers everything else; whether it is a file resource in the declaration or a thing the
executor fetches by digest.
- **Reload.** A push that adds or upgrades a module's bundle reaches a running executor as a
reload, not a restart, or every tool on the node blinks on every push.
- **The host's own questions.** [Issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)
wants a machine to say more about itself. With an executor on every node, "what is this
machine made of" is a seat verb like any other, served there.
@@ -0,0 +1,65 @@
# 04 — The seats of the environment
*Candidates, not decisions. To-be 33 says which verbs a seat serves is a decision per seat,
taken slowly, because a seat's tools bind every future holder. This document lists the roles the
operator's machine has once, who could hold each, what gates it, and a first verb or two — so
each record has a starting point.*
## The rule for what is a seat here
A role the machine fills **at most once** is a node-scoped seat, declared by the module family
that fills it ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). A thing
several of which coexist without contention — editors, browsers, media players — is not a seat;
each is a module with its own tools, and nothing is singular about it. A seat is held by one
assignment per node; other modules of the same family may be installed beside it without
holding it ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) §1, read with the sharper
distinction: *installed* is not *holding*).
## Candidate seats
| seat | holders | gated by | first verbs |
|---|---|---|---|
| **login shell** | zsh, fish, bash | nothing: universal | `execute(command)`; `show-config`; the holding itself sets the account's login shell through the host's `user` shape |
| **service manager** | systemd | the `service-manager` capability the profile reports | units: list, status, start, stop, restart, enable, journal; **user scope** on each |
| **boot** | grub, systemd-boot | a machine that boots itself (not a container host) | `rebuild-images`; `entries` |
| **package manager** | pacman, apt | the `package-manager` capability | search, installed, upgrade, orphans; today a capability the host uses, not a seat anyone holds |
| **display server** | xorg, wayland compositors that are their own server | the `graphical-session` capability | `displays`; `layout` |
| **display session** | i3, sway | the display server seat held on the node; i3 needs x11, sway needs wayland | `reload`; `workspaces`; `windows`; `move` |
| **terminal emulator** | xterm, alacritty, foot | display session | `open`; `font` |
| **launcher** | rofi, dmenu | display session | `show`; `theme` |
| **notifier** | dunst, mako | display session | `send`; `history`; `rule` |
| **compositor** | picom | display server (x11 only) | `restart`; `effects` |
| **lock screen** | i3lock, swaylock | display session | `lock` |
| **bar** | i3status-rust, waybar | display session | `reload`; `blocks` |
| **login manager** | lemurs, greetd | graphical session | `sessions`; `default-session` |
| **audio** | pipewire, pulseaudio | the machine reports a sound device | `sinks`, `sources`, `default`, `volume`, `mute` |
| **clipboard** | greenclip, cliphist | display session | `history`; `clear` |
Not seats, modules with their own tools: the editor, the browser, the mail client, the file
manager, the media player, the chat client, the agent at the terminal, the downloads folder, the
scripts folder, the sync client, the power and thermal daemons that are specific to one machine's
hardware.
## What the table implies
**Capabilities come first.** `graphical-session`, `service-manager` and `package-manager` are
reported today. *A display server is held* is not a capability but a seat being held, and a
module that needs it declares a dependency on the seat, not on a capability: *i3 needs the
display server seat held by xorg*. Whether a held seat can gate another's assignment is a
question for the controller's resolver, and the first environment module after the shell will
ask it.
**The service manager comes early.** Four of the predecessor's modules ship user units, and the
executor itself is a unit. User scope on the host's `service` shape is a host change whichever
module holds the seat; the seat's holder answers the questions about units, it does not apply
them — the host does, as it does for every declared resource.
**The shell comes first.** Universal, no capability, one verb that is immediately useful on
every node, and the `user` shape already makes the login shell declared state. It is the module
that proves the pattern: a package, files under the home owned by the account, a seat claim,
tools served by the executor, settings for the few things that vary per node, and a kept region
for the operator's own lines.
**The login manager is the first system-scope one**, because it needs nothing new: a package,
two files under `/etc`, a service — the same shape the ssh daemon module has today — and the
session script it owns is the file that was hand-fixed the day this effort opened.
@@ -0,0 +1,55 @@
---
status: active
initiated: 2026-10-02
touches: [lab, the lab module, the catalogue, assignments, settings, the controller's store]
became: []
---
# 019 — A warm twin of the running mesh
## What is being investigated
Whether the lab can keep a **warm twin of the mesh as it actually runs**: the same machines, carrying
the same catalogue, the same assignments and the same settings as the live mesh, raised once and kept
ready, so that a change can be tested against the mesh as it is rather than against a scenario
written to resemble it. A run against the twin would go through the lab module like any other run:
a branch per repository, the twin restored from its snapshot, the change applied, the beds run.
## Why
The lab's beds raise meshes from declarations written for the bed. They prove the mechanism. They
do not prove that a change works on the mesh that runs, with its accumulated assignments, its
operator settings, its adopted machines and its modules in their real combinations. The gap showed
on 2026-10-02:
- a change to how a module's settings reach its files was correct in every bed, and would have put a
setting into the container runtime's configuration on every machine running that module. Only the
composed plan for a real machine showed it;
- a firewall change composed cleanly and still left one machine's wired port unfiltered, because
of a link that machine had and no bed did;
- a recovery step was needed on every machine at once, after a change that every bed had passed.
The lab already has a warm mode, a snapshot of a raised scenario restored between attempts. What it
does not have is a scenario that **is** the running mesh, kept current with it.
## What it touches
- **What a twin is made of.** The catalogue and the assignments are records; settings are records;
secrets are sealed to machines and cannot be copied. Which of these can be carried to the lab as
they are, which must be substituted, and how a twin says what it substituted.
- **Data.** A twin with the real catalogue and no real data proves composition and delivery, not a
migration. Whether a twin carries data, a sample of it, or none.
- **Keeping it current.** A twin raised once goes stale with the first merge. Whether it is
re-derived from the live records on each run, refreshed on a schedule, or rebuilt only when asked.
- **Machines.** The live mesh has machines of different kinds: a server on the internet, machines
behind a home router, a laptop that sleeps. Which of their properties a twin must reproduce for a
test to mean anything (reachability, the private network, the found firewall).
- **Cost.** The lab machine's memory and disk, and how long a twin takes to raise from cold.
- **The lab module's tools.** A run against the twin rather than a named bed: one more tool, or an
argument to the run tool.
## Starting point
The lab module (ADR 0172) runs beds through the mesh, and the lab's warm mode already snapshots and
restores a raised scenario. The beds that raise a machine shaped like one live machine from the
catalogue are the nearest existing thing, and the first to compare against.
@@ -0,0 +1,34 @@
---
status: graduated
initiated: 2026-10-03
touches: [the tool runtime, the catalogue's tool bundles, the controller's declaration composer, settings, own secrets, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
became: [02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
---
# 020 — What a bundled tool is given
## What is being investigated
How a module's tools, once they are a bundle the node's runtime loads
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)),
learn the things their container used to be handed: where the module's configuration file is, where
its token or password is, which port the service listens on, where a provision's address is written.
A container is given these as an environment and mounts, composed by the mesh per module per machine.
A bundle has no environment of its own: the runtime's process carries four words for every bundle it
loads, and nothing per module ([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4).
## Why
Two holders moved on 2026-10-03 — the packet filter and the intrusion prevention — and both could,
because neither needs anything but a fixed path and root. Of the thirty-three modules whose tools
still run as containers on the runtime's image, thirty-one are not like that: their environment
names a configuration file, a credential file, a service address, a grants directory. Moving them
one by one without a rule for this would give the mesh thirty-one answers to one question. The
measurement and the options are in [01](01-what-the-containers-are-given.md).
## What it touches
The runtime (which hands a bundle what it is given), the composer (which resolves `${dir:…}` and
`${port:…}` for a container today and would for a bundle), the manifest (where a bundle would say
what it needs), and design 38, which records the gap and must say the rule once there is one.
@@ -0,0 +1,86 @@
# What the tool containers are given, measured
Counted 2026-10-03 in the catalogue, after the two holders moved.
| | |
|---|---|
| modules whose tools still run as a container on the runtime's image | 33 |
| tool containers among them (two modules run two) | 36 |
| modules whose container's environment carries only the bus credential | 1 (the intrusion prevention, now moved) |
| modules whose container's environment carries more | 32 — 31 still containers |
## What "more" is
Every value a container is given is one of five shapes. The reference kinds the composer resolves
in those values, over the 36 containers: a module directory (`${dir:…}`) in all 36, a mesh-chosen
port (`${port:…}`) in 12, a seat and an access grant once each.
1. **A file the mesh already places on the host, mounted in.** The module's configuration as
JSON (`…_CONFIG_FILE`), its own secret (`…_TOKEN_FILE`, `…_PASSWORD_FILE`, `MESH_BROKER_FILE`),
a provision's address and secret written for it. Every one is a path under one of the module's
directories — its mesh state, its state, its grants, what it has written — mounted at a path of
the container's choosing and named to the tool through the environment. **The file is on the
host already; only the name under which the tool finds it is the container's.**
2. **The service's address, with the port the mesh chose:** `http://127.0.0.1:${port:3000}`. The
port is the composer's; the rest is the manifest's constant.
3. **A provision's address as a constant string** (a database's URL on the module's own network
name), paired with a mounted secret file from shape 1.
4. **A directory of grants** (`MESH_RECEIVES`): shape 1 again, a directory rather than a file.
5. **Literals the image needs:** a time zone, a user id, a memory limit. These belong to the
service's container where one exists; a tool bundle needs none of them.
So the whole of what a bundled tool needs is: the paths of its module's directories on this
machine, the ports the mesh chose for its module here, and the constants its own manifest wrote.
Nothing a container had that a bundle cannot have; the mesh composes all three for the container
today, per module per machine.
## What the runtime already has for it
- The SDK's tool contributor is `(env) => tools`, and `collectTools(env)` takes the environment to
hand each contributor. The runtime calls it without one, so every contributor reads the process's
— the four words. The hook for a per-module environment exists and is unused.
- A launched bundle ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md))
is spawned with the runtime's environment; the launch takes an environment argument.
- The composer resolves `${dir:…}` and `${port:…}` for a container's `env` and `volumes`; the same
resolution over a bundle's declaration is the same code.
## Options
**A. The bundle declares its environment on its artifact, and the mesh composes it as a
container's.** The manifest's tools artifact gains `env`, resolved with the same references;
values that were mount targets become the host-side paths directly (`${dir:mesh-state}/config.json`
rather than `/run/config/config.json`). The controller composes one environment per bundle per
machine into the runtime's declaration; the runtime hands it to the bundle's contributor and to a
launched child, and to nothing else. *For:* the tool code does not change — it reads the same
names; the conversion of the thirty-one is a mechanical move of the container's `env` with the
mounts folded in; one rule, one place. *Against:* the runtime's process carries thirty-one
environments in its declaration, and a bundle's environment is visible to the other bundles in the
process unless the runtime keeps them apart, which it must — a tool that reads `process.env`
instead of the environment it was handed would see its neighbours' paths.
**B. The runtime derives the environment from the module's placed manifest.** No new field: the
runtime reads, for each module it serves, where that module's directories and ports are, and hands
a conventional set of words. *For:* nothing to declare. *Against:* a convention the tool code must
be rewritten to, thirty-one times; the runtime learns the composer's job; a module that names its
file `config.json` and one that names it `settings.json` need different words anyway.
**C. Tools read their module's files through the bus** — ask the controller. *Against:* a tool
that cannot start without the bus answering a question is a tool that fails in the one case the
tools exist for, and a secret crossing the bus to reach a file already on the machine is a
disclosure for nothing.
A is the one that keeps the tool code and the composer's vocabulary as they are, and names the one
thing the runtime must add: an environment per bundle, kept apart. The thing to decide beside it:
whether a bundle's environment may name a secret file at all, or whether secrets stay mounts in
spirit — a path the tool reads, never a value in the environment — which is what every container
does today and what A keeps if the rule says *paths, not values*.
## What a decision would have to say
- Where a bundle says what it is given (the artifact, option A), and that values are paths and
constants, never a secret's content.
- That the composer resolves it with the references it already has, per module per machine.
- That the runtime hands each bundle its own environment and nothing of another's, and how that is
checked: a test loading two bundles whose environments differ and asserting each sees only its own.
- That the thirty-one move in one mechanical change after the rule lands, each proven by its tools
answering from the runtime, and the registration gate then refuses the container shape for all.
@@ -0,0 +1,48 @@
---
status: graduated
initiated: 2026-10-03
touches: [the console, 03-DESIGN/01-to-be/34-the-console.md, the tool runtime, seats, assignments]
became: [02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md, 03-DESIGN/01-to-be/34-the-console.md]
---
# 021 — Finding a tool in the mesh
## What was investigated
How an agent finds the one tool it needs among everything the mesh answers, and how a call names
exactly what it asks — a role the mesh holds once, a role every machine holds, or one assignment of a
module on one machine — rather than receiving the whole catalogue and a name that can mean several
things.
## Why
The operator's observation on 2026-10-03: *Claude should not see all tools at once; they should be
discoverable — and `postgres.list_databases` is wrong, asking one machine's postgres is not asking
another's.* Measured the same day from the console's own answer:
| | |
|---|---|
| tools announced to every session at its start | 228, in 110 KB |
| names (module or seat prefixes) | 43 |
| node seats' verbs, which require `node` | 22 |
| modules with tools on more than one machine | 4 — fail2ban, nftables (every machine), postgres, mssql (two each) |
| modules reported "not answering", most with no tools and several retired | 47 |
The two stateful modules on two machines are listed **once**, with `node` optional and *whichever
answers* when it is left out — though their two instances hold different databases. Design 34 §3 says
such a module is listed once per machine; the live console does not do that. The list is taken once
per session, so a tool that arrives later is invisible until the client reconnects. And only Claude
Code's own deferral of long tool lists keeps the 228 from the model's context; another MCP client
would receive them whole.
## Options
1. **Keep the flat list; rely on the client to defer it.** Rejected: a property of one client, and it
leaves the ambiguity and the stale list.
2. **One flat tool per assignment** (`ace_postgres_list_databases`). Removes the ambiguity, multiplies
the list, and runs into the API's tool-name limit (letters, digits, `_`, `-`, 64 characters).
3. **A small fixed set of tools that walk the mesh's own structure**, with the full address as an
argument: the mesh's seats; a machine's node seats and assignments; a search; a description; a
call. Chosen — see [ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md).
4. **MCP resources or prompts for discovery.** Clients support them unevenly, and an agent acts
through tools; a resource it cannot be relied on to read is not a discovery path.
@@ -0,0 +1,41 @@
---
status: graduated
initiated: 2026-10-03
touches: [the tool runtime, the per-module containers, the SDK, the bus grants, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
became: [02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
---
# 022 — Where a module's long-running code runs
## What was investigated
Twenty-three modules still run their own code in a container built on the runtime's image. Their tools
can move as bundles ([ADR 0192](../../02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
[ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md));
the rest of what those containers run cannot yet. This asks where that code goes and how it reaches
what its container handed it.
## What that code is, measured 2026-10-03
| | modules |
|---|---|
| subscribes to events on the bus | audit-logger (everything), mesh-catalog (two seat events), mesh-vault, records (`gitea.pull.merged`), and postgres, mongodb, mssql, redis, mosquitto logging their own lifecycle |
| provisioners: read the grants the mesh delivered as files, act on the backend, emit | 12 |
| a run-once preparation step | mesh-catalog |
| a command-line client of the backend | psql, mosquitto_ctrl, git (packages on every machine's system); mongosh, sqlcmd (not in its repositories) |
| a service reached by a container name | icecast, mailu-admin, minio, mongodb-server, mssql |
| a main of its own | anthropic-consumer, openai-consumer, route-adapter |
A provisioner needs nothing a launched bundle lacks: files named by its words, its backend, and an emit
that already travels through the runtime. The one thing missing is **a subscription** — events
delivered to the module's code, acknowledged when it has handled them.
## Options
1. **The runtime launches it and is its bus**: the stdio channel gains a subscription; the runtime
binds the module's durable consumer and delivers each event to the child, acknowledging when the
child answers. One bus connection per machine; any language. Chosen.
2. **A process per module with its own bus client and credential.** Every language's SDK would carry
a transport and every module a credential on disk — what ADR 0188 rejected for tools, for the same
reasons.
3. **Keep the containers for this code.** Leaves ADR 0188's rule broken for 23 modules indefinitely.
@@ -0,0 +1,37 @@
---
status: active
initiated: 2026-10-03
touches: [the seats, the seat protocol, the controller's ownership check, 03-DESIGN/01-to-be/26-the-seats.md]
---
# 023 — A seat protocol that defines what its holder owns
## What is investigated
**A seat is a definition — a protocol — and a module occupies it by implementing that protocol.**
Today the protocol is what the holder accepts, emits and serves (ADR 0118, 0129, 0132): its verbs, as MCP
tool definitions. This asks whether the protocol should also name the **files and directories the
holder owns**, so that occupying the seat means owning them: `node-resolver-config` owns
`/etc/resolv.conf`, `node-hosts-file` owns `/etc/hosts`, the intrusion prevention owns its jail file.
The direction is the protocol's, not the holder's: the seat states what any holder must own; a module
that wants the seat must declare those paths among its resources, or the controller refuses the claim
as not implementing the seat. Two seats may not name one path.
## Why
Who owns a singular file is today answered by reading every manifest, and enforced only after the fact,
when two modules on one machine both declare the same path. The question *which module owns
`/etc/resolv.conf`?* came up on 2026-10-03 with no place to look it up. A seat that names the path answers
it from the seat table, before any module is written, and makes "implements the seat" checkable.
## What it touches
- The seat definition and its table (ADR 0122) — a new part of the protocol.
- The controller's ownership check (`checkResources`), which already refuses two modules owning one path.
- Every node seat that is really about a file: `node-resolver-config`, `node-hosts-file`
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)),
`node-intrusion-prevention`, `node-packet-filter`.
Raised by the operator during the resolver work of ADRs 0194–0199 and parked there so that work was not
widened by it.
@@ -0,0 +1,152 @@
---
status: graduated
initiated: 2026-10-04
touches: [the bus, what a module declares, the tool runtime, the SDK, the bus grants, 03-DESIGN/01-to-be/25-the-bus-on-nats.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md]
became: [02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
---
# 024 — State a module keeps on the bus
## What is investigated
A place on the bus where a module's own code keeps **current state** — not history — that every
machine sees, including a machine that joins after the state was written: put, get, delete, list and
watch, reached through the node's runtime the way a bundle already publishes, asks and subscribes
([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
On NATS that is a key-value bucket. The questions are what a module declares, who creates the
bucket, what the grants are, what the runtime's verbs are, and what may never be stored.
## Why
The mesh carries two kinds of module traffic and a third is missing.
- **Events** land in the EVENTS stream: limits retention, seven days, ten thousand messages per
subject, a durable consumer per consuming module that replays what it missed. Never a secret
([design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
- **Requests** are core request/reply — tool calls, a bundle's `mesh/ask` — and are kept nowhere.
Neither is *the current value of something*. Two cases from the first module that needs it, the
operator's agent on a machine ([design 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
1. **An MCP server registered for every machine.** Registering emits an event every machine's copy
of the module consumes. A machine the module is assigned to *after* the registration has no
durable consumer yet — the consumer is created at assignment — so it never hears of it. Wanted
instead: one entry per server, for every machine or for one; every machine reads the whole current
set when it starts and watches for changes; unregistering is a delete; any machine can list it.
2. **Which licence a machine is bound to** ([design 39](../../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)).
As events, a machine that was off for a day replays every rotation since and asks for a token
after each. It needs only the latest binding and its generation. The token itself stays on
request/reply and is never stored.
The design already expects this. [Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1:
"conditions and observed state in key-value buckets that anything may watch".
[Research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md) wants a provisioner's
"what I applied" and a rotation's step kept in one rather than in memory. Nothing implements it.
## What exists, measured 2026-10-04
| | fact | where |
|---|---|---|
| streams | five kinds of mesh stream: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (limits: 7 days, 10 000 per subject), one work queue per seat that accepts | the controller's broker streams |
| the state relationship | [design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* — 1:1, last per subject — and says it is "declared: the mesh's own". Two streams use it, both written by the controller. No module can declare it | design 32, the controller |
| key-value buckets | none, anywhere | all four code repositories |
| the runtime's bus verbs | `mesh/publish`, `mesh/ask`, `mesh/subscribe`; delivery back to the bundle is `mesh/event` | the runtime's launcher |
| the runtime's principal | one bus user per machine carries every assigned module; its grant is the union of theirs. That one module's code does not act as another is the runtime's to keep: it publishes under the module's own name by construction | the controller's grant composition, the runtime's bus |
| what a bundle is issued | a membership per assignment, last per subject, read directly by the runtime: where it serves, where it emits, what it reaches | ADR 0160 |
| who creates bus objects | the controller only — mesh streams on every raise, a seat's stream at registration, a module's consumer at assignment. No module reaches the JetStream API | design 25 §3 |
### What a key-value bucket needs from a grant, against a real server
Measured against nats-server 2.10 with the Go client the runtime already uses, a bucket created by
an unrestricted user and used by two users holding only the subjects below (`B` is the bucket):
| operation | subject published | writer | reader |
|---|---|---|---|
| bind to the bucket | `$JS.API.STREAM.INFO.KV_B` | yes | yes |
| get | `$JS.API.DIRECT.GET.KV_B.>` | yes | yes |
| put, delete | `$KV.B.>` | yes | **refused** |
| list keys, watch | `$JS.API.CONSUMER.CREATE.KV_B.>` — an ordered, ephemeral consumer | yes | yes |
| stop a watch cleanly | `$JS.API.CONSUMER.DELETE.KV_B.>` | yes | yes |
| answers | its own inbox, which every principal already subscribes | — | — |
*Checked again once built, 2026-10-04:* the grants the controller composes for two machines' runtimes —
one carrying the owner, one only a reader — were loaded into a server as composed, and each operation
was run as each runtime's user. The owner's did all of them; the reader's read, listed and watched,
and its put and delete were refused by the server.
Three things the measurement showed that reading the documentation would not have:
1. **A refused put is not an error to the caller; it is a timeout.** The server reports the
permission violation asynchronously, on the connection, and the client waits out its deadline
for an acknowledgement that never comes. So a runtime that relies on the grant alone tells a
bundle "timed out" for "you may not write this" — it must refuse first, from what the module was
issued, with the reason.
2. **A watch's current values include deletions.** A key deleted earlier arrives among the initial
values as a delete marker, before the end-of-current marker. A bundle asking "what is there now"
must not be handed those.
3. **Without the consumer-delete grant, stopping a watch hangs** until its deadline, and the
ephemeral consumer lingers on the server until it times out by itself.
### Whether the events shape is enough instead
Honestly compared, because a new primitive is a cost:
- **EVENTS cannot be made last-per-subject for some subjects.** Retention is per stream, and
JetStream refuses a second stream overlapping the first (verified and recorded in design 32 §3).
A state subject inside `mesh.mod.*.event.>` keeps EVENTS' seven days: a licence binding unchanged
for a week disappears.
- **A separate last-per-subject stream per module** is possible — it is exactly what a key-value
bucket *is* on the server: a stream with one message per subject, a rollup for purge, and direct
reads. Building it by hand gives up the client's get, list, delete and watch, which are the
operations both cases need, and would be the mesh writing NATS's own key-value layer again.
- **Consumers are the wrong reader.** A durable consumer per reading module is created at
assignment and replays from where it is; state wants "everything current, now, then changes",
which an ordered ephemeral consumer from the last value per subject gives and a durable does not.
So key-value is not a convenience over events; it is the state relationship design 32 already
names, opened to modules.
## Questions, and what this effort proposes
1. **What a manifest says.** `state` names the buckets a module owns, by local name — every
instance of the module may write them and read them. `reads` names another module's bucket as
`<module>.<name>`, read-only. Names only, never a bucket or subject (design 32 §1). A bucket's
options — how many past values it keeps, how long a value lives — are the owner's to declare,
the way a seat declares its own retention (design 32 §3).
2. **Scope.** One bucket per module per name, mesh-wide. A key may carry a machine by the module's
own convention (`all.<server>`, `<machine>.<server>`). A bucket per machine was considered and
not proposed: "list every server for every machine" becomes a walk over buckets, and the grant
could only narrow writes, which nothing asked for — every instance of the owner already writes.
3. **Who creates the bucket.** The controller, from the catalogue, on every raise — a bucket exists
from registration, like a seat's stream, so a reader can watch before the owner is assigned
anywhere. Never a module.
4. **The runtime's verbs.** `mesh/state.get`, `mesh/state.put`, `mesh/state.delete`,
`mesh/state.keys`, `mesh/state.watch`, each naming the bucket as the module named it. A watch
is answered once the current values are on their way, then each change is delivered to the
bundle as a `mesh/state` request it answers — current values first (no deletions among them), an
end-of-current marker, then changes. A child that restarts watches again, as it subscribes
again. The runtime refuses, with the reason, a bucket the module was not issued, and a write to
one it only reads.
5. **Secrets.** None in a bucket, sealed or not: a bucket is a stream (design 32 §10). Sealed values
are plain base64 and cannot be recognised, so the mechanical check is partial and said to be: the
runtime refuses a value carrying a field whose name says it is a credential (`password`,
`secret`, `token`, `authorization`, …), which catches the ordinary mistake and not a determined
one. For the first consumer this has a concrete consequence: an MCP server registered with an
authorisation header keeps that header out of the bucket.
6. **History, lifetime, size.** One value per key unless the owner says more; no expiry unless it
says one; a value at most 256 KiB and a bucket at most 64 MiB, the mesh's caps rather than a
module's. **A bucket outlives its module's assignment** — what a module stored is data, and data
outlives what declared it ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md));
unassigning is not cleaning up. A bucket whose declaration is gone is reported, never removed.
7. **Events or state.** State (above).
## The work, once decided
1. A decision record, then design 32 (*state* becomes a relationship a module declares) and design
25 (key-value buckets are part of the bus) amended.
2. The controller: the manifest's two words and their registration check; buckets asserted on every
raise; the grants for owners' and readers' runtimes; the buckets issued in each membership.
3. The runtime: the five verbs, the watch delivery, the refusals; tested against a real server.
4. The SDK, TypeScript and Go: a small state surface over the verbs.
5. Proved on a running mesh with one small module, then handed to the operator's agent, whose
registered servers move from events to a bucket.
@@ -0,0 +1,94 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
- 02-DECISIONS/0182-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
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
---
# 025 — How a module plugs into the operator's shell
## What is investigated
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
only module that needs a line there. A prompt theme loads itself from it. A language version manager
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
names the browser. Today all of these sit in one hand-written file, and the shell module as written
carries some of them in its own block and loads others only "if a module placed them". Nothing says
how they get placed.
This effort asks four things:
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
lands.
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
`execute` verb, and programs a graphical session starts.
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
machine does today.
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
operator's). The record this becomes says which.
## Why
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
- Every machine carries the same predecessor-written startup file, so the module's block would be
appended after its own older copy and everything would run twice.
- The block drops lines the machines rely on today.
- Nothing installs the prompt theme or the plugins the block loads.
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
written into.
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
becomes its own module; assigning the shell module must lose no functionality; and the environment,
`PATH` above all, needs an answer of its own.
## What it touches
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
`receives` pair or a new gathered field like `jails` (to-be 31).
- The controller's composition, if the controller assembles the text.
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
naming the files its holder owns.
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
manager, a toolchain.
## Where it stands
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
that alone writes the account's environment. It writes a file that shells source and the service
manager's user environment, from the variables and `PATH` entries every other module contributes to
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
which moves into the mesh's own seat set beside the new `node-environment` (§6).
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
environment module's own code, renders the environment into the module's files, so that the result
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
option 6b).
## Documents
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
@@ -0,0 +1,103 @@
# 01 — What the shell file holds today
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
four have the account's login shell set to zsh, zsh installed from the distribution, and a
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
## The startup file is the same everywhere
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
both servers, and one shared by both workstations. So the "per-machine" part is really a
per-*kind*-of-machine part.
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
- installed fonts;
- changed the login shell.
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
servers have none of them.
## What the 102 lines are
Sorted by who should own each line once the machine is modules:
| Lines today | What they are | Natural owner |
|---|---|---|
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
| two variables naming the operator's own script library | environment, the operator's own | the operator |
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
The workstation variant of the local file adds:
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
one that sources a function file another module places;
- a hook sourcing a further per-node file.
The server variant holds only that last module-placed source line.
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
runs 54. Of a workstation's 65:
- about a quarter (15) are environment;
- about half are interactive defaults no other module cares about;
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
agent's functions), loaded from the shell file only because there was nowhere else to put it.
## The shell module as written
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
- the title hook, keybindings and the most common aliases, minus the port aliases;
- guarded `source` lines for the theme and two plugins *if present*;
- the source of `~/.zshrc.local`.
- assigned to any of the four machines, appends that block after the identical lines already there, so
every line in it runs twice, `~/.zshrc.local` included.
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
## Which startup file reaches what
zsh's startup order, and what each path through it reads:
| started as | reads |
|---|---|
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
| non-interactive: a script, `ssh host command` | `.zshenv` only |
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
display manager and the service manager, not from a shell, and read none of these files. The service
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
that a terminal does.
## What the mesh already has for "many modules, one file"
Measured over the catalogue's 69 module definitions:
| mechanism | used by | shape |
|---|---|---|
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
None of these is a contribution of shell code or of environment today.
@@ -0,0 +1,197 @@
# 02 — How a module plugs in
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
what it has decided.
## 1. The environment: variables and `PATH`
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
and it is wanted by:
- every shell, interactive or not;
- the login shell's `execute`;
- a graphical session's programs.
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
only interactively.
| | option | for | against |
|---|---|---|---|
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
document's first position (E2, then E3).
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
environment.
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
contributor changes when the login shell does.
Sketched, for a node with zsh, the environment module, and a toolchain:
```
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
│ (held by node-env)
┌──────────────────┴──────────────────┐
▼ ▼
POSIX export file ~/.config/environment.d/
▲ ▲
sourced from ~/.zshenv read by the service manager
(every zsh, execute too) (the graphical session)
```
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
the values a person varies become settings of whichever module contributes them (ADR 0174).
## 2. Shell code: a prompt, plugins, a version manager's loader
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
syntax highlighting last.
| | option | for | against |
|---|---|---|---|
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
module touching both makes two contributions:
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
owned file (ADR 0182).
- A version manager contributes its directory variable to the environment, and its loader as code
for each shell it supports.
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
## 3. The operator's own lines: the "local override"
Assigning the shell module must lose nothing the machine does today. That has two halves.
**What is common is the module's default, not an override.** The startup file is identical on all four
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
default, or another module's contribution. It is not a local override that a person would keep in step
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
the contributions above. Little of it stays the operator's.
**What is the operator's is everything outside the mesh's block.** The host already works this way:
- the mesh's region is the marked block;
- text outside it is kept byte for byte, and checked unchanged;
- the region is given back when the module goes.
| | option | for | against |
|---|---|---|---|
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
not because the mesh's block does.
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
never by an edit), and only its description of which side is marked changes.
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
- remove from today's file every line the block or a contribution now carries;
- keep the rest below the block.
Until a prompt module and the other contributors exist, the lines they will carry stay among the
operator's own. Nothing is lost at any step.
## 4. Order
Order matters only for code. The environment is set before any code runs, because zsh reads
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
environment module orders, not the shell.
| | option | for | against |
|---|---|---|---|
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
**Starting position: R2.** Inside the shell module's block, the order is:
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
of this list is `.zshrc`);
2. the `first` slot;
3. the shell module's own defaults;
4. the `normal` slot;
5. the `last` slot.
The operator's lines come after the block, as option O1 says.
## 5. Who renders: the controller or the holder's code
There are two different renderings, and E6 lets them be answered differently.
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
facts in the mesh's own format, rendered by the receiver).
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
That is ADR 0182's third class, written by the module's own process, owned by the account,
atomically.
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
- To settle: what runs that code when the received file changes. The candidates are a host action
that restarts on the received file, or a subscription through the runtime (ADR 0198).
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
applies it.
## 6. What a contribution is addressed to
Under E6 there are two addressees: the environment and the login shell.
| | option | for | against |
|---|---|---|---|
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
- `node-environment` is new, and is the mesh's from the start.
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
service manager, and a protocol that now carries duties (render the shell code contributed to it,
source the environment file) should not depend on one module's registration.
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
environment seat would own the two environment files, and the login-shell seat the shell's
startup-file region. The two efforts should not decide it twice.
## Open questions
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
assigned. Measured, this may need nothing new.
- What a node without the environment module does with environment contributions: refuse them at
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
is a visible gap, not a broken machine.
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
are the operator's lines below the shell block, or a kept region of the environment module's file.
The first needs nothing new, but reaches only interactive zsh.
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
- Whether the `execute` verb should read the interactive file at all once the environment is in
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
command needs, and the prompt's code should not run for it.
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
non-holding shell module may render contributions for interactive use.
@@ -0,0 +1,74 @@
---
status: active
initiated: 2026-10-04
touches:
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became: []
---
# 026 — The graphical session as modules
## What is investigated
The workstations' graphical session as modules of the mesh, at the same level as the shell
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)): a package, files
under the account's home, a seat, and nothing that names a machine. The pieces are:
- the login manager;
- how a session starts and what environment it gets;
- the display server (X today, Wayland as a sibling);
- the window manager (i3, and sway as its Wayland sibling);
- the terminal emulator (xterm);
- the session's companions: bar, compositor, launcher, notifier, lock and idle, clipboard,
wallpaper, theming, fonts.
[To-be 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) names this WP7, and says
each seat begins with a record naming its holders and verbs. [To-be 37](../../03-DESIGN/01-to-be/37-the-operators-machine.md)
§4 leaves one question for the resolver: whether a held seat can gate another's assignment.
## Why
The operator asked for the graphical modules next, at the shell's level, and for one consistent
experience across machines. Since the predecessor retired, nothing manages the workstations'
desktops. Measured in [01](01-what-the-workstations-run.md):
- Two workstations carry one 983-line predecessor module's output, still byte-identical in its core.
- One workstation also carries another machine's hardware fragments.
- One runs a session that predates two fixes, with two notification daemons and two portals.
- The session's environment is a hand-kept second copy of the account's, beside the one the mesh
now writes.
## How it is approached
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
and remove the leftovers. Every module's design lists its improvements over today. **Every module
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
package and a file is unfinished. The tools are catalogued in
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
## What it touches
- **The seat table:** up to ten node seats.
- **The resolver:** a seat held on a node gating another module's assignment.
- **The contribution mechanism of ADR 0204:** whether it generalises beyond shells, or whether
tools' own drop-in directories serve.
- **The host's user-scoped units** (mesh-host #72, still open).
- **Settings** for per-machine values (issue 168).
- **ADR 0205's archive** for the two pieces the distribution does not package.
## Documents
- [01 — What the workstations run](01-what-the-workstations-run.md): evidence.
- [02 — The questions and the options](02-the-questions-and-the-options.md)
- [04 — Screensaver, displays and menus](04-screensaver-displays-and-menus.md): the lock and idle module, monitor layouts by the monitors' identity, rofi and dmenu behind one launcher seat, the clipboard, fonts
- [05 — The tools each module serves](05-the-tools-each-module-serves.md): a first catalogue for the modules of 026 and 027
- [03 — What the predecessor taught](03-what-the-predecessor-taught.md): its 128 modules and 3,395 commits, as patterns to keep and failures not to repeat; shared with research 027.
@@ -0,0 +1,141 @@
# 01 — What the workstations run
Measured 2026-10-04 on the two workstations of one installation, read-only: a laptop with a hybrid
GPU and an internal panel, and a desktop with one GPU and two external monitors. Both run the same
predecessor-generated desktop. File equality was checked by checksum across the two machines.
## How a session starts
The chain is the same on both:
1. The login manager (`lemurs`, built from the distribution's user repository, its package now in
the official one) runs its X setup script on a virtual terminal.
2. That script sources the login shell's profile files, then `~/.xprofile`, then the system's
`xinitrc.d` drop-ins, then merges `~/.Xresources`.
3. `~/.xprofile` reuses the systemd user manager's bus, then sources `~/.xinitrc`.
4. `~/.xinitrc` sets up the session and ends with `exec i3`.
The login manager's own window-manager entry (`exec startx`) is never reached. Its configuration
file uses a format two releases old, and an unmerged newer one sits beside it.
**What `~/.xinitrc` does**, in order:
1. Sources the system drop-ins, which import `DISPLAY` and `XAUTHORITY` into the user manager.
2. Starts the keyring and exports its ssh socket.
3. Exports the session's environment:
- `PATH`, with nine entries, one of them a directory that no longer exists;
- toolchain variables;
- `XDG_CONFIG_HOME` and `XDG_DATA_DIRS` (with flatpak);
- five GTK/Qt theme variables;
- the desktop's identity (`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`);
- three of the operator's own variables.
4. Imports an explicit allowlist of ten of those into the user manager and D-Bus activation. It is
never `--all`, because:
5. a predecessor file of **secrets as environment variables** (package-registry and API tokens) is
sourced next.
6. Sets the screensaver and display power timeouts, restores the wallpaper, and starts the lock
watcher in a respawn loop. It is deliberately not a unit, because it needs the login session.
7. `exec i3`.
**The account's environment, as of today, has three sources that disagree:**
- this file, for the session;
- the mesh's `environment.sh`, for shells
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
- `~/.config/environment.d/`, for the user manager. It holds the mesh's `50-mesh.conf`, and a
predecessor file that **sets `PATH` outright** and sorts after it.
## The roles, and what fills them
| role | software | where configured |
|---|---|---|
| login manager | lemurs | `/etc/lemurs/*` (identical on both, and to the predecessor's source) |
| session start and environment | the login manager's X setup, `~/.xprofile`, `~/.xinitrc`, `xinitrc.d`, the D-Bus import, `environment.d` | `~/.xprofile`, `~/.xinitrc`, `~/.config/environment.d/*` |
| display server | Xorg (`xorg-server`, `xinit`, the X apps; vendor drivers per GPU) | **no** `xorg.conf.d`; monitors by `xrandr` scripts |
| monitor layout | `xrandr` scripts (arandr), a hotplug rule on the laptop | `~/.screenlayout/`, a scripts folder, a window-manager fragment |
| window manager | i3 4.25 | `~/.config/i3/config` and `config.d/*`, a reload watcher (user unit) |
| bar | i3bar with i3status-rust | `~/.config/i3status-rust/*`, 14 themes, a bar watchdog (user unit) |
| terminal | xterm (the only terminal installed) | `~/.Xresources.d/xterm`, the window manager's binding, the compositor's opacity rule |
| compositor | picom | `~/.config/picom/picom.conf` |
| launcher and menus | rofi | `~/.config/rofi/*`, launcher, power-menu and theme-picker scripts |
| notifier | dunst (D-Bus activated) | `~/.config/dunst/dunstrc`, `dunstrc.d/*` |
| lock, idle, display power | xss-lock and i3lock-color, `xset` | `~/.xinitrc`, a lock script |
| clipboard | greenclip, xclip | `greenclip.toml` |
| wallpaper | feh | `~/.fehbg` (points into the predecessor's tree) |
| theming | Adwaita dark, qt5ct/qt6ct, the desktop portal (GTK backend pinned) | GTK `settings.ini`, `qt*ct.conf`, `portals.conf`, an appearance script, `.Xresources` cursor |
| fonts | Hack and Meslo Nerd fonts in `~/.local/share/fonts` (not packaged), noto | `~/.Xresources.d/xft` (DPI fixed at 96) |
| keyboard | nothing set; the default layout; vendor keys via triggerhappy on the laptop | window-manager bindings, `/etc/triggerhappy` |
**Packages:** every piece except two is in the distribution's official repositories, and the login
manager now is too. The two exceptions are the lock screen's colour build (`i3lock-color`) and the
clipboard manager (`rofi-greenclip`). The Nerd fonts exist as official packages, but both machines
carry hand-copied files instead.
## Identical, different, and why
**Byte-identical on both machines:**
- the session files: `.xinitrc`, `.xprofile`, `.Xresources` and its drop-ins;
- the i3 main configuration and two of its fragments;
- the bar's top configuration and themes;
- picom, rofi, the GTK and Qt settings, the portal configuration, the login manager.
**Different, by cause:**
| cause | what |
|---|---|
| hardware | the monitor layout script; the bar's battery block; the laptop's power and vendor-key units and udev rules |
| misassignment | the desktop carries the **laptop's** hardware fragments: the vendor-key daemon and its triggers, the backlight rule, the brightness drop-in, a touchpad reset, and the laptop's monitor layouts, in an older version |
| drift | the notifier's position and corner radius; a "temporary" window-manager fragment from a test; the bar watchdog disabled; a second Qt configuration tool; different font builds |
| a stale session | the desktop's session began before two fixes, so it runs two notification daemons and two portals, and its user manager lacks the desktop's identity |
**Dead references:** the window manager starts a polkit agent that is installed on neither machine,
so there is no polkit agent at all. `PATH` names a directory that does not exist.
**Per-machine values inside shared files:**
- the DPI;
- absolute home paths, in the clipboard configuration and the flatpak data directories;
- the laptop's panel name, inside a fragment both machines carry.
## User units the desktop needs
| unit | does | laptop | desktop |
|---|---|---|---|
| reload watcher | reloads the window manager and bar when their files change | on | on |
| bar watchdog | restarts a dead bar | on | off |
| clipboard daemon | from its package | via the window manager | unit **and** window manager |
| vendor power profile, memory guard | laptop power | on | — |
None is managed. Applying them as the account needs the host's user scope
([ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)),
which is still an open change.
## The predecessor's module
One manifest of 983 lines covers the window manager, bar, launcher, notifier, compositor, lock
screen, session bootstrap, theming and scripts. It:
- has four *flavors*: i3, laptop (i3 plus the monitor wizard and hotplug), desktop (i3 plus
nothing) and a laptop model (laptop plus vendor keys);
- has about **105 theme variables** substituted into templates: border, gaps, fonts, workspace
names, every colour of bar, launcher, notifier and lock screen, compositor opacity, cursor, idle
times, Qt and GTK theme names;
- enables the two user units from an install hook.
Separate modules held the login manager and the display server (one flavor, `xorg`, with a comment
calling `wayland` "the intended sibling"). The shell module held no graphical part.
## Wayland and sway
**Nothing exists.** There is no compositor, no sway configuration, no Wayland session entry, and the
login manager's Wayland directory is empty. What is installed is libraries:
- Wayland itself and the Qt Wayland plugins, which other packages pull in;
- `xwayland`, explicitly installed and required by nothing;
- on the desktop, an orphaned compositor library from another desktop environment, and that
environment's portal backend, pulled in by a game launcher. The portal configuration pins
against it.
Every piece a sway session needs is in the official repositories: the compositor, its lock screen,
a terminal (`foot`), a bar (`waybar`), a notifier (`mako`) and `xwayland`.
@@ -0,0 +1,123 @@
# 02 — The questions and the options
Seven questions. Each has its options and a starting position, which is what this effort tests, not
what it has decided.
## 1. How finely the desktop splits into modules
| | option | for | against |
|---|---|---|---|
| G1 | One desktop module, as the predecessor had | one assignment | flavors again, per machine; ADR 0174 refuses them, and the evidence shows a flavor landing on the wrong machine |
| G2 | **One module per piece of software:** `lemurs`, `xorg`, `i3`, `i3status-rust`, `xterm`, `picom`, `rofi`, `dunst`, `xss-lock` with the lock screen, `greenclip`, `feh`, a theme module, a fonts module | each is what it declares; a machine gets exactly what is assigned; the same split already works for the shell and its plugins | about thirteen assignments per workstation |
| G3 | G2, plus a named **set** the controller assigns as one (for example *the X desktop*) | G2's precision with G1's convenience | a set is a new controller concept |
**Starting position: G2.** Whether a set is worth a record is left until the thirteen assignments
have been done by hand once.
## 2. The seats
Research 018 listed the candidates. ADR 0204 has since put the login shell in the mesh's own set,
because a role with a protocol should not depend on one module's registration. The same reasoning
applies here:
| seat | holders | protocol, first verbs |
|---|---|---|
| `node-login-manager` | lemurs, greetd | which sessions it offers, the default session |
| `node-display-server` | xorg, sway | `displays`, `layout` |
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
| `node-terminal-emulator` | xterm, foot, alacritty | which terminal `$TERMINAL` names; `open` |
| `node-bar`, `node-compositor`, `node-launcher`, `node-notifier`, `node-lock-screen`, `node-clipboard` | the pieces above, and their Wayland counterparts | one verb or none each, until a use asks for one |
**A compositor that is its own server holds two seats.** Sway is both the display server and the
display session. A module may claim several seats, so this needs nothing new.
**Starting position:** the first four seats are in the mesh's own set. The companion seats are
added only as each holder is written; for those, a module without a seat is acceptable at first.
## 3. One module requiring another seat to be held
i3 needs an X server held on its node, and sway needs nothing below it. A terminal needs a session.
To-be 37 left open how that is said.
| | option | for | against |
|---|---|---|---|
| R1 | A seat **delivers a provision** (`x11-display`, `wayland-display`) and a module requires it at node scope. The seat table has a `delivers` field already, and requirements already resolve | existing machinery; the refusal names the seat and its possible holders, which design 27 already lists | a node-scoped requirement that never crosses machines has to be stated as such |
| R2 | A new field, *needs the seat X held* | reads plainly | a second way to say what R1 says |
| R3 | Nothing; assign carefully | — | the mistake the evidence shows (a laptop's fragments on a desktop) is exactly an unchecked assignment |
**Starting position: R1.** `xorg` and `sway` each deliver what they serve. `i3`, `picom` and `xss-lock`
require `x11-display`. `foot` requires a Wayland display, and xterm requires an X one, which a Wayland
session gives through `xwayland`.
## 4. Who starts the session, and with what environment
Today `~/.xinitrc` is a hand-kept second environment and the session's whole start script.
| | option | for | against |
|---|---|---|---|
| S1 | The display server's module writes `~/.xinitrc` **into**: a mesh block at the start that sources the account's environment (`environment.sh`), merges the X resources, and runs the session's contributed start lines. The session holder's module contributes its `exec` line. The operator's lines stay after the block | one environment for shells, the session and the user manager; nothing to keep in step | the order inside `.xinitrc` becomes the slot order of a contribution (question 5) |
| S2 | The login manager's module owns the session script under `/etc` | system scope; no home file | the environment is the account's, and the script is the same for every account |
| S3 | Leave `.xinitrc` the operator's | nothing to build | the third environment stays |
**Starting position: S1.**
- The desktop's identity (`XDG_CURRENT_DESKTOP`) and the theme variables become **environment
contributions** (ADR 0203) from `i3` and from the theme module. They then also reach the user
manager through `environment.d`, which replaces most of today's allowlist import.
- The secrets file stays out of the environment until research 027 settles how a secret reaches an
account.
## 5. How other modules contribute to a holder's file
The terminal's settings are X resources. A bar, a launcher binding and a hardware module's key
bindings are window-manager configuration. Autostarts are the session's. ADR 0204 built slot
contributions for shells only.
| | option | for | against |
|---|---|---|---|
| C1 | **The tool's own drop-in directory**, where it has one: i3's `include`, dunst's `dunstrc.d`, X resources' `#include`, XDG autostart entries, `environment.d`. Each contributor owns its own file there | no mesh change; the tools already read these directories; unassigning removes the file | each contributor names a path in another tool's directory (ADR 0204 rejected this for shells, where no drop-in convention exists); ordering is by file name |
| C2 | **ADR 0204's mechanism generalised:** `contributes` text *for a format* (`zsh`, `xresources`, `i3`, `xinitrc`) in a slot, placed by the holder's placeholder | one mechanism, checked by the controller, order declared | every format must be named in the controller; a bigger change to ADR 0204 |
| C3 | C1 where the tool has a drop-in convention, C2 where it does not (`.xinitrc`, `.Xresources` order) | uses each tool's own grain | two mechanisms to learn |
**Starting position: C3**, with the boundary drawn by the tools. A tool that reads a directory gets
drop-ins. A file without one gets slots. This means amending ADR 0204's "shell" to "a format", which
is a progressive extension rather than a reversal.
## 6. What varies per machine
| what | today | option |
|---|---|---|
| monitor layout | per-machine `xrandr` scripts, monitor names baked in | a **setting** of `xorg` (issue 168), and a `layout` verb of the display server seat |
| DPI, fonts' size | fixed in an X resource | a setting |
| battery block, vendor keys, brightness, touchpad | a laptop model's flavor | **a hardware module** per machine model, contributing its window-manager fragment, bar block and udev rules. The desktop simply is not assigned it |
| theme (the 105 variables) | template substitution | settings of each tool's module, after issue 168 closes (ADR 0174). Until then each module carries today's values as its default |
**Starting position:**
- Hardware modules for what follows the machine.
- Defaults now, settings after issue 168, for what the operator varies.
- The monitor layout waits for settings. Until then it is an operator-owned script the display
server's block calls if present.
## 7. Wayland and sway
Nothing of a Wayland session exists, and every piece is officially packaged. "Wayland" is a protocol,
not a piece of software, so it has no module of its own. Its parts are `sway` (server and session),
`swaylock`, `foot`, `waybar`, `mako`, and `xwayland` for X clients.
**Starting position:**
- The seats and the requirements (questions 2 and 3) are designed so that sway fits from the first
day.
- The X stack is built first, because it is what runs.
- `sway` and its companions are written after that, and proven on one workstation as a second
session the login manager offers beside i3. That lets the operator try it without losing the
working desktop.
## Prerequisites this effort cannot remove
- **User-scoped units** (mesh-host #72) for the reload watcher and the bar watchdog.
- **Settings** (issue 168) for monitors and theme values.
- **The two packages not in the official repositories:** the lock screen's colour build and the
clipboard manager. Each is ADR 0205's case, a pinned archive, or a choice of an official
alternative (`i3lock` without colours; `clipmenu`/`cliphist`).
@@ -0,0 +1,51 @@
# 03 — What the predecessor taught
A study on 2026-10-04 of the retired predecessor:
- its 128 module manifests, their hooks, its installer and its sync engine;
- 3,395 commits of history;
- what it left on four machines.
This document holds what bears on the graphical session and on the system layer
([research 027](../027-the-system-layer-as-modules/00-overview.md)). The evidence is in the
predecessor's history. A commit is cited here by what it fixed, not by its hash, because the
repository is private.
## Keep: what worked
| pattern | where it shows | in the mesh |
|---|---|---|
| ownership marked inside the file: inside the markers is reconciled, outside is kept verbatim | a block marker in a shared file, after an engine that rewrote whole files | kept regions (ADR 0174, ADR 0204) |
| two writers get two files and an `include`, the include first | the ssh client's configuration, after two writers fought over one file | ADR 0203's two files; research 026 C1 |
| one writer per file, one authority per action | only the reload watcher restarts the window manager, after three mechanisms each did | ADR 0182 |
| refuse to write when the source of truth is unreadable; never empty a block because a query found nothing | a block of names was emptied by a failed query | — keep |
| an unresolved template variable fails the install | a literal unfilled path was installed green | [issue 231](../../04-ISSUES/231-a-misspelled-placeholder-is-written-out-as-text/00-report.md): the mesh still has this gap |
| prune only what you can prove you placed | stale files from earlier deliveries | ADR 0189 |
| ensuring never rotates a credential | a silent rotation caused a retry storm, a ban of the shared address and a lost registry | ADR 0114 |
| vendor only the files you use; never clone and link | three files instead of 77 MB | ADR 0205 |
| copy, never symlink | a recursive delete followed a link, and every reinstall failed | ADR 0012 |
| verification says what it did not check | a verifier said *clean* while the secret was still on disk | — keep |
| alert once per condition | 411 alerts hid a 28-hour outage | ADR 0090 |
## Do not repeat
| failure | what it did | the mesh instead | where the mesh is still exposed |
|---|---|---|---|
| **Flavors** | variant files and packages per machine type: a gate dropped, the first-seen variant won, packages never installed, the verifier ignored the gate. On the day of the study a desktop carried a laptop model's fragments | one module per piece, assignment per machine (ADR 0174, research 026 §1) | a setting that switches which whole file is rendered is a flavor under another name |
| **The freeze** | existing values outranked new defaults; templated files were rendered once (*copy if absent*) | files are generated (ADR 0011) | a created-once file (ADR 0087) is a deliberate freeze, and a push must say *kept* |
| **Adopting drift** | a *merge* strategy made a local edit the record forever; switching strategies clobbered a person's model choice | nothing is read back (ADR 0174) | an edit outside a kept region is overwritten **silently**. The predecessor's *why is this back* loop: the push should name what it overwrote |
| **Environment templating** | `${VAR}` matched any name; unresolved names stayed literal; comments and destination paths were interpolated | namespaced placeholders; `$` forbidden in contributed values (ADR 0203) | issue 231 |
| **Hooks with privilege** | install hooks ran `sudo`, `chsh`, `systemctl`, `git clone` and `curl`, and swallowed failures into a warning | the `user` shape, the service shape, archives (ADR 0176, 0177, 0205) | the agent module writes under `/etc` from its own tool through `sudo` (no keep-original, no give-back); the prompt's helper downloads itself unpinned; that the operator escalates without a prompt is assumed by three modules and declared by none (research 027) |
| **Secrets in environment files** | `.env` files left world-readable; the decryption key beside what it decrypts; a deleted secret stayed in the file, so rotation was a no-op | the vault (ADR 0113, 0114) | a predecessor file of secrets is still sourced into the graphical session on two machines (research 027 Q2) |
| **Symlinks into a home** | a system file linked into a person's home | ADR 0012 | on the control machine, a fail2ban action file is still a predecessor link into its home tree. Deleting that tree would silently break the repeat-offender jail. The mesh's fail2ban module must own it as a file first |
| **Green while broken** | a recorded version frozen for four months; a verifier passing what it skipped | ADR 0134, 0145, 0184 | issue 230: a plan waiting for ever reads as healthy |
## What it means here
- **Research 026:** the desktop's 88 flavor-gated files and 92 theme variables are the flavor and
templating failures in one module. Question 1 (one module per piece) and question 6 (hardware
modules, settings later) are the answer, and nothing in the new modules may switch whole files on a
setting.
- **Research 027:** the hooks that installed the AUR helper, enabled the login manager and changed
shells are what the `package`, `service` and `user` shapes replace. Every remaining `sudo` in a module's
own code is a debt to be named, starting with the agent module.
@@ -0,0 +1,129 @@
# 04 — Screensaver, displays and menus
Three areas the operator named on 2026-10-04, as their own modules. Each sharpens a row of
[01](01-what-the-workstations-run.md) and a question of [02](02-the-questions-and-the-options.md).
## The screensaver: idle, lock and display power
**Measured on both workstations:**
- **Idle and lock** are three things wired by hand in the session's start script:
- the X screensaver timeout (`xset s 1800`);
- the display power timeouts (`xset dpms`);
- `xss-lock` running the colour build of `i3lock` through a wrapper, in a respawn loop.
- **A second screensaver,** xscreensaver, is installed and deliberately not started. Earlier it
overrode the display power settings with its own, and locked nothing. Its configuration file is
still in the home.
- **The lock screen's 20-odd colours and formats** were predecessor theme variables.
- **The colour build is not in the official repositories** (research 026/01).
**Starting position:**
- **One module for the lock screen,** holding `node-lock-screen`: the locker and its wrapper as the
module's own files, the screensaver and display power timeouts, and `xss-lock`.
- The timeouts and colours are its defaults, and settings later (issue 168).
- The colour build ships as ADR 0205's pinned archive, or the module uses the official `i3lock`.
That is the operator's choice, and the colours are the only difference.
- xscreensaver is not a module; its package and file are removed.
- `xss-lock` needs the logind session, so it stays a session-start line contributed into
`.xinitrc`'s block (question 4), not a unit.
## Monitor layout (xrandr)
**Measured:**
- Each workstation has a layout script generated by `arandr`, with the monitor names baked in. One
workstation also has several layouts for named places, a hotplug rule and a wizard.
- **The desktop carried the laptop's layout scripts.**
- No `xorg.conf.d`, and no layout tool beyond the scripts.
**Starting position: `autorandr`** (official repositories) inside the display server's module.
- `autorandr` saves a layout as a profile **keyed by the connected monitors' identities** (their EDID)
and applies the matching one at login and on hotplug.
- Profiles therefore need no machine's name. A profile can be shared mesh-wide and simply never
matches on a machine without those monitors. That is exactly the "say it by what is there, never by
a name" rule (ADR 0112).
- The profiles are the operator's data, saved by the tool itself, so they are *found* (ADR 0182). A
`layout` verb on `node-display-server` lists, saves and applies them.
- The arandr scripts and the hotplug rule retire once a profile exists for each.
## Menus: rofi and dmenu
**Measured:**
- rofi is the launcher, the power menu, the theme picker and the clipboard menu.
- The operator's scripts call `rofi -dmenu` in four places and **plain `dmenu` in two. dmenu is
installed on neither workstation, so those two fail.**
**Starting position:**
- **`rofi` holds `node-launcher`**, and the seat's protocol includes a **dmenu-compatible command**:
read choices on standard input, print the chosen one. Scripts call that command, not a program by
name.
- **`dmenu` is a module of its own** (official repositories), able to hold the same seat on a machine
that wants it, for instance a Wayland session where `wofi` or `fuzzel` would hold it instead.
- The rofi module carries its theme files, and the menus that belong to other modules arrive as those
modules' scripts:
- power menu → the session;
- clipboard menu → the clipboard module;
- theme picker → settings, once issue 168 closes.
## The clipboard: xclip and greenclip
**Measured:**
- **greenclip** keeps the clipboard's history, and rofi shows it on a key binding.
- **greenclip is not in the official repositories.**
- It is started two ways: the window manager's configuration starts it on both workstations, and on
one a user unit is enabled as well.
- Its configuration names an absolute home path.
- **xclip** (official) is the command-line clipboard the operator's scripts use.
**Starting position:**
- **`xclip` is a module of its own,** a package and nothing else. It is the tool scripts depend on,
and a module that needs it requires it.
- **The clipboard manager holds `node-clipboard`:** its daemon, started once by the session (a session
contribution, or a user unit once user-scoped units ship, never both), its configuration with no
absolute path, and its menu binding contributed to the window manager.
- **Which manager holds it is the operator's choice:**
- greenclip, as today, shipped under ADR 0205;
- or `clipmenu` (official), which feeds the same dmenu-compatible command as the launcher seat above,
and needs no archive.
- **On Wayland** the same seat is held by `cliphist` with `wl-clipboard`, both official.
## Fonts
**Measured:**
- The fonts the desktop uses are **hand-copied files** in the account's font directory, not packages:
- a Nerd font for the window manager, the bar and the terminal;
- a second one for the prompt;
- on one workstation, the same four files twice, once under URL-encoded names;
- on the other, a different build of the same font and three more copied from a theme's repository.
- The system's default monospace is a different font (`Noto Sans Mono`), so anything that asks for
`monospace` gets another face than the terminal.
- The DPI is fixed in an X resource.
- **Every Nerd font in use is in the official repositories** (Hack, Meslo, Iosevka, JetBrains Mono).
**Decided** (the operator left the choice open, except that it must not be today's Hack):
| role | face | why |
|---|---|---|
| monospace: terminal, window manager, bar, launcher, prompt | **JetBrains Mono Nerd Font** | built for long reading in a terminal, unambiguous `0O1lI`, optional ligatures; a version-3 Nerd font, so every icon the prompt and bar use is present |
| interface: GTK, Qt, notifications | **Inter** | designed for screens, clear at small sizes |
| icons missing from any face | Nerd Fonts Symbols | a fallback, so a font without icons still shows them |
| emoji | Noto Color Emoji | |
| serif and every other script | Noto | |
All five are official packages.
**Starting position:**
- **A `fonts` module:** those packages, and a fontconfig file it owns that maps `monospace`, `sans-serif`,
`serif` and the emoji and symbol fallbacks to the chosen faces, so every program agrees.
- The terminal, bar, launcher and prompt modules name the family, not a file.
- The DPI becomes the display server's setting (issue 168).
- The copied files are removed by the operator once the packages are in (ADR 0182).
- Fonts are not a seat: several coexist. The module owns the one place where *the* default is said.
@@ -0,0 +1,77 @@
# 05 — The tools each module serves
A first catalogue for the modules of research 026 and 027, as the operator asked: "all kinds of useful
tools for all these modules". Each tool is served by the node's runtime (ADR 0175), on the machine the
module runs on. Through discovery (ADR 0195) it is reachable from any machine as
`<machine>/<module>.<tool>`, or as `<machine>/<seat>.<verb>` where a seat defines it.
**Conventions:**
- **(r)** reads.
- **(a)** acts on the machine, escalating where it must, as the packet filter does (to-be 38 WP4).
- **(d)** is a desktop act that needs the operator's session.
- A tool that changes something a module declares says so in its answer: the next push restores the
declaration.
- Every tool answers structured data, not prose (issue 229).
- **Seat verbs** (marked *seat*) are the protocol every holder of that seat serves. The rest are the
module's own.
## The graphical session (026)
| module | tools |
|---|---|
| `xorg` (*node-display-server*) | *seat* `displays` (r: outputs, modes, rates, connected monitors with their identity) · *seat* `layout` (r/a: list, save, apply an autorandr profile) · `set-mode` (a: one output's resolution, rate, rotation, scale) · `primary` (a) · `dpi` (r/a) · `input-devices` (r) · `input-set` (a: touchpad tap, natural scroll, pointer speed) · `keyboard` (r/a: layout and options) · `screenshot` (d: one screen or all, as a file) · `x-log` (r: the server's errors since start) |
| `i3` (*node-display-session*) | *seat* `reload` (a) · *seat* `workspaces` (r) · *seat* `windows` (r: tree with classes, titles, workspaces) · `focus` (d: window or workspace) · `move` (d: window to workspace or output) · `layout-save` / `layout-restore` (d: a workspace's arrangement) · `exec` (d: start a program in the session) · `kill` (d) · `bindings` (r: every key binding and what it runs) · `config-check` (r: validate the composed configuration before a reload) · `marks` (r) · `scratchpad` (d) |
| `sway` (*node-display-server*, *node-display-session*) | the same seat verbs over Wayland, plus `outputs` (r) and `idle-inhibitors` (r) |
| `lemurs` (*node-login-manager*) | *seat* `sessions` (r: what the login screen offers) · *seat* `default-session` (r/a) · `logins` (r: who logged in when, from the journal) |
| `xterm` (*node-terminal-emulator*) | *seat* `open` (d: a terminal, optionally running a command, in a directory) · `font` (r/a: face and size) · `colours` (r) |
| `i3status-rust` (*node-bar*) | *seat* `reload` (a) · `blocks` (r: what the bar shows and each block's current value) · `block-run` (r: run one block once and answer its output) · `themes` (r) |
| `picom` (*node-compositor*) | *seat* `restart` (a) · `rules` (r: opacity, shadow and blur rules in force) · `window-opacity` (d) · `toggle` (d: compositing off and on, for a game or a test) |
| `rofi` (*node-launcher*) | *seat* `menu` (d: show a list, answer the chosen line: the dmenu-compatible command as a tool) · `applications` (r: the desktop entries it would offer) · `themes` (r) · `run` (d) |
| `dmenu` (*node-launcher*) | *seat* `menu` (d) |
| `dunst` (*node-notifier*) | *seat* `send` (d: title, body, urgency, actions) · *seat* `history` (r) · `pause` / `resume` (d: do not disturb) · `close-all` (d) · `rules` (r) · `count` (r: shown, waiting, history) |
| lock module (*node-lock-screen*) | *seat* `lock` (d) · `idle` (r/a: screensaver and display power timeouts) · `inhibit` (d: keep the screen on for a while) · `locked` (r: is the session locked now, and since when) |
| clipboard manager (*node-clipboard*) | *seat* `history` (r: entries, newest first, length-limited) · *seat* `copy` (d: put text on the clipboard) · `paste` (r: what the clipboard holds now) · `clear` (d) · `delete` (d: one entry) |
| `xclip` | `copy` (d) · `paste` (r): the plain clipboard without a manager |
| `feh` (wallpaper) | `set` (d: an image, per output) · `current` (r) |
| `fonts` | `families` (r: installed faces) · `match` (r: what `monospace`, `sans-serif` and `emoji` resolve to) · `glyph` (r: which installed font has a given character) · `cache-rebuild` (a) |
| theme module | `appearance` (r/a: dark or light, for GTK, Qt and the portal at once) · `cursor` (r/a) · `icons` (r) · `portal-check` (r: which portal backend answers which interface) |
| `gnome-keyring` (*node-secret-service*) | *seat* `unlocked` (r) · `lock` (d) · `collections` (r: names and item counts, never secrets) · `ssh-keys` (r: what the agent holds, by fingerprint) |
| desktop hardware module (laptop) | `brightness` (r/a: panel and keyboard) · `battery` (r: charge, health, cycles, limit) · `charge-limit` (r/a) · `gpu-mode` (r/a: integrated, hybrid, discrete) · *seat* `profile` (r/a: quiet, balanced, performance) · `thermals` (r: temperatures and fan speeds) · `power-draw` (r) |
## The system and the account (027)
| module | tools |
|---|---|
| `docker` (*node-container-runtime*, ADR 0166) | *seat* `list`, `inspect`, `logs`, `stats`, `start`, `stop`, `restart` (r/a) · `images` (r: with size and which container uses each) · `prune` (a: dangling images, stopped containers not held by the mesh, build cache, with a dry run first) · `disk-usage` (r) · `networks` (r) · `volumes` (r: with what mounts each and whether the mesh holds it) · `events` (r: the last hour) · `daemon-config` (r) |
| `docker-compose` | `projects` (r: compose projects running and where their files are) · `up` / `down` / `restart` (a: one project, by directory) · `logs` (r) · `ps` (r) |
| `sudo` | `rules` (r: what the account may run, without a prompt and with one) · `check` (r: does the escalation the mesh relies on work here) |
| `pacman` | `search` (r) · `installed` (r: with version and explicitly or as a dependency) · `info` (r) · `owns` (r: which package owns a path) · `files` (r) · `updates` (r: what an upgrade would change) · `upgrade` (a: with the news first) · `orphans` (r) · `remove-orphans` (a) · `cache` (r/a: size, clean to the last N versions) · `history` (r: installs and upgrades from the log) · `mirrors` (r/a: rank and refresh) · `news` (r: distribution news since the last upgrade) |
| AUR (package repository, 027 question 1) | `search` (r) · `build` (a: on the build machine, into the mesh's repository) · `outdated` (r) · `published` (r) |
| `snapd`, `flatpak` | `list` (r) · `install` / `remove` (a) · `update` (a) · `runtimes` (r) · `disk-usage` (r) |
| `time-sync` | `status` (r: synchronised, offset, server) · `servers` (r) · `sync-now` (a) |
| `localization` | `get` (r: locale, time zone, keymap) · `time-zone` (r/a) · `locales` (r) |
| `kernel` | `running` (r: version, command line, uptime) · `installed` (r) · `modules` (r: loaded, with what uses them) · `reboot-needed` (r: a newer kernel or library than the one running) · `microcode` (r) · `boot-entries` (r) · `initramfs-rebuild` (a) · `dmesg` (r: errors since boot) |
| `logrotate` | `status` (r: last rotation per log) · `force` (a: one configuration) · `big-logs` (r: the largest logs on the machine) |
| `avahi` | `browse` (r: services on the local network) · `resolve` (r) |
| `cups` | `printers` (r) · `queue` (r) · `cancel` (a) · `print` (a: a file to a printer) · `default` (r/a) |
| `bluetooth` | `devices` (r: paired, connected, battery where reported) · `connect` / `disconnect` (a) · `scan` (r) · `power` (r/a) |
| `ssh-client` (owns `~/.ssh`) | `hosts` (r: every `Host` and where it came from: the mesh, a module, the operator) · `check` (r: modes, keys without a passphrase, keys unused for a year, stale `known_hosts` entries) · `authorized` (r: who may log in, by fingerprint and comment) · `revoke` (a: one authorized key, into the operator's region) · `known-host` (r/a: verify, refresh one host's key) · `test` (r: can this machine reach a host and authenticate, batch mode) |
| `sshd` | `sessions` (r: who is logged in, from where) · `config-effective` (r: `sshd -T`) · `failed-logins` (r: since a time, with fail2ban's verdicts) |
| scripts modules | `list` (r: each script with its one-line description) · `run` (a: one script by name with arguments, as the account, bounded like `execute`) · `which` (r: which module ships a command) |
| `node-env` (*node-environment*) | `show` (r: every variable and `PATH` entry with the module that contributed it) · `diff` (r: what a shell actually has versus what the mesh composed) |
| `zsh` (*node-login-shell*) | *seat* `execute` · `zsh_config` (r) · `history-search` (r: the account's history, by pattern) · `functions` (r: aliases and functions in force, with where each came from) · `startup-time` (r: how long an interactive shell takes to start, per slot) |
| `memory-pressure` | `status` (r: memory, swap, compressed swap ratio, pressure stall) · `top` (r: the largest processes) · `oom-history` (r: what was killed, when) |
| `zfs` | `pools` (r: health, capacity, fragmentation) · `datasets` (r) · `snapshots` (r/a: list, create, destroy by name) · `scrub` (r/a: status, start) · `errors` (r) · `arc` (r: cache statistics) |
| `nfs-server`, `samba` | `exports` / `shares` (r) · `clients` (r: who has it mounted now) · `reload` (a) |
| `nfs-client`, `smb-client` | `mounts` (r: each share, mounted or not, and since when) · `mount` / `unmount` (a) · `test` (r: is the server reachable, is the export offered) |
| hosts-file holder (*node-hosts-file*, ADR 0199) | *seat* `entries`, `add`, `remove` |
| `vnstat`, `lm_sensors` | `traffic` (r: per interface, day, month) · `sensors` (r) |
| mail consumer (future effort) | `accounts` (r) · `search` (r) · `unread` (r) · `read` (r: one message) · `mark` (a) · `send` (a) |
## What this catalogue is for
It is a starting list, not a contract. A tool becomes a contract only when it is a seat's verb, and
each seat's verbs are decided in that seat's record (ADR 0132). A module's own tools can grow freely.
Every row above is a tool the operator would otherwise run by hand over ssh. That is the measure of
whether one is worth writing.
@@ -0,0 +1,64 @@
---
status: active
initiated: 2026-10-04
touches:
- 02-DECISIONS/0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md
- 02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
became: []
---
# 027 — The system layer as modules
## What is investigated
What runs on the machines below the operator's home and outside the mesh's own services, and which
of it should be modules. That covers:
- the container runtime and its tools;
- privilege (sudo);
- the package manager and the software it cannot install;
- time, locale, the kernel and boot;
- log rotation;
- the machine-specific daemons the workstations and servers carry: printing, bluetooth, VPN
clients, virtualisation, storage, sharing.
## Why
The operator asked for the system level beside the graphical session. In particular:
- a `docker` module (decided in principle by the proposed ADRs 0165 and 0166, never built);
- a `docker-compose` module for development work, assigned **only to the two workstations**.
Measured in [01](01-what-the-machines-run.md): on four machines, almost nothing at this level is
owned by a module. The pieces differ by machine for no recorded reason. Three findings are security
matters on their own.
## How it is approached
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
and remove the leftovers. Every module's design lists its improvements over today. **Every module
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
package and a file is unfinished. The tools are catalogued in
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
## What it touches
- **The container runtime seat** (ADRs 0165 and 0166, both proposed).
- **The host's `package` shape**, which installs from the distribution's official repositories only,
while the workstations carry 67 and 114 packages from elsewhere.
- **How a secret reaches the account's environment.** ADR 0203 forbids it in the contributed
environment, but a predecessor file supplies such secrets today.
- **The facts the mesh assumes and never declares,** above all that the operator account escalates
without a prompt.
## Documents
- [01 — What the machines run](01-what-the-machines-run.md): evidence.
- [02 — Candidates and questions](02-candidates-and-questions.md)
- [03 — The account's own tools](03-the-accounts-own-tools.md): `~/.ssh` as one module's, scripts on every machine, the keyring, the laptop's power management, mail as events
- The predecessor's lessons, shared with research 026: [026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
@@ -0,0 +1,103 @@
# 01 — What the machines run
Measured 2026-10-04 on four machines, read-only, including the host's own record of what it applied:
two servers (the anchor and a home server) and two workstations (a laptop and a desktop). "Owned"
means a module the mesh assigns declares it.
## The container runtime
| | anchor | home server | laptop | desktop |
|---|---|---|---|---|
| docker | 29.8.2 | 29.8.2 | 29.7.2 | 29.7.2 |
| compose | 5.5.1 | 5.6.0 | 5.5.0 | 5.5.0 |
| buildx | 0.37.2 | — | — | — |
| podman | — | 6.1.3 | 6.1.0 | 6.1.0 |
| `docker.socket` | disabled | enabled | enabled | enabled |
| `containerd.service` | disabled | disabled | disabled | **enabled** |
| `daemon.json` beyond the shared keys | direct routing, two more insecure registries | log rotation (100 MB × 10) | — | — |
| docker group | operator, **a CI user** | operator | operator | operator |
**Ownership:**
- The `docker` package is owned on one machine only, by the installer's bootstrap, not by a module.
- `docker.service` is declared indirectly, by the name resolver and the private-network modules,
which each merge their own keys into `daemon.json`.
- Nothing owns the socket, containerd, compose, buildx or the group.
**Compose in use:**
- On the servers, no running container belongs to a compose project. Their compose files are
pre-mesh trees under the operator's and root's homes, plus a dangling enabled unit for one of them.
- On the workstations, compose runs development stacks, and pre-mesh service trees sit under a
top-level directory.
The mesh marks its own containers with a host label. On the workstations, a handful of unlabelled
development and test containers run beside its build agent.
## Privilege
- The operator account escalates **without a prompt on all four machines**. The mesh relies on this,
but it is set by hand in `/etc/sudoers` (a `wheel` rule on two machines, the account named on
two), and nothing declares it.
- On the anchor, a **CI user from the predecessor** keeps passwordless sudo and docker membership,
and a predecessor drop-in in `sudoers.d` survives.
- On the desktop, the operator account is also in the **`root` group**.
## The package manager
- `pacman.conf` is stock except on one server (parallel downloads).
- The mirror list was generated once by a tool that is no longer installed. On the anchor, it is the
hosting provider's single mirror.
- An AUR helper is installed everywhere.
- **Packages from outside the official repositories:** 2 on the anchor, 21 on the home server,
67 on the laptop, 114 on the desktop. They include:
- the agent CLI, which a catalogue module declares as a package and the host cannot install;
- a VPN client;
- a remote-access client;
- printer drivers;
- GPU tools;
- a kernel module built from source (DKMS) for a storage filesystem;
- a snap daemon.
## Time, locale, kernel, boot
| | anchor | home server | laptop | desktop |
|---|---|---|---|---|
| time zone, keymap | **another zone**, a non-US console keymap | local zone, unset | local zone, unset | local zone, unset |
| time sync | timesyncd plus a provider drop-in | timesyncd | timesyncd | **ntpd**, timesyncd disabled |
| bootloader | grub (BIOS) | systemd-boot **and** grub | systemd-boot | systemd-boot **and** grub |
| kernels | one | two, plus a DKMS filesystem module | one | one, plus a DKMS controller driver |
| microcode | **none** | yes | yes | **none** |
| swap | RAID partition | partition | zram, a file and a partition | partition |
| log rotation timer | not found | enabled | not found | not found |
## Daemons and services no module owns
- **All four:** avahi.
- **Workstations:**
- a VPN client daemon (both);
- virtualisation (incus) with a hand-made unit that inserts container-runtime firewall rules (both);
- printing and bluetooth;
- GPU and power tuning per model;
- a remote-access daemon (laptop);
- snap and flatpak (desktop);
- the local model server, run from a hand-written unit although a catalogue module for it exists
(desktop);
- Samba sharing and a network filesystem mount from the home server (desktop). A second mount is
failing, and its **credential is written in clear in `/etc/fstab`**.
- **Servers:**
- a storage pool (about 167 TB) with its import, mount and scrub units, an NFS server and Samba
sharing (home server);
- traffic and sensor monitoring (home server);
- a DHCP client daemon the catalogue has a module for but does not assign there (home server);
- cron, an entropy daemon, and the **legacy `iptables` services**, which run beside the mesh's own
filter (anchor).
- **Not found anywhere:** a backup agent, a monitoring agent, a second VPN mesh.
## What is plain debris
- Dangling enabled-unit links on three machines.
- Predecessor blocks in `/etc/hosts` on both servers.
- The CI user, and the predecessor sudoers drop-in, on the anchor.
- Pre-mesh compose trees on the anchor, the home server and the desktop.
- Unlabelled test containers on the workstations.
@@ -0,0 +1,128 @@
# 02 — Candidates and questions
## Decided by the operator on 2026-10-04
- **`docker`** holds the container runtime seat on every machine, as ADRs 0165 and 0166 propose. Those
records are promoted from proposed when it is built.
- **`docker-compose` is a module of its own,** the distribution's package and nothing else. It is
assigned **only to the two workstations**, for development work. The servers run nothing through
compose.
Later the same day, on the candidates below:
- **Yes, all of them:** `docker`, `docker-compose`, `sudo`, `pacman`, an AUR helper (question 1),
`time-sync`, `kernel` (with boot and microcode), `logrotate`, `avahi`, `cups` with the printer's
driver, and every server-only candidate.
- **Locale, time zone and keymap are one module, `localization`.**
- **`snapd` and `flatpak`** are modules, on the two workstations only.
- **`incus` is the lab's,** whose module depends on it. It is not a module of its own beside the lab.
- **The agent's and the local model server's modules are still being developed,** and are not
assigned until they are.
- **The predecessor's CI user is retired.** It was removed from the anchor the same day, with its
sudoers line, its docker membership and a dangling unit link; the backup is on the machine.
## Candidate modules
**On every machine:**
| module | owns | first reason |
|---|---|---|
| `docker` | the packages (runtime, containerd), the service and socket, `daemon.json`'s base keys (live restore, log rotation), the docker group's members | four machines, four configurations, one owner on one |
| `sudo` | the operator account's escalation as a drop-in, declared | the mesh's tools rely on it (to-be 38 WP4) and nothing states it |
| `pacman` | `pacman.conf`'s few keys, the mirror list and its refresher, cache cleaning | mirrors generated once and never again |
| `time-sync` | timesyncd and its drop-ins | two daemons across four machines |
| `localization` | locale, time zone, console keymap (one module, the operator's choice) | one machine differs, with no record why |
| `kernel` | the kernel packages, microcode, initramfs presets | two machines without microcode |
| `logrotate` | the timer and the base configuration | rotation runs on one machine of four |
| `avahi` | the daemon and name-service switch entry | on all four, owned by none |
**On the workstations only:**
- `docker-compose`;
- `lemurs`, the login manager (research 026);
- a VPN client module;
- `incus` with its forward unit (the lab module declares the package on one workstation only);
- `cups` with the printer's driver;
- `bluetooth`;
- per-model **hardware** modules: GPU, power, vendor keys, brightness. These are the same modules
research 026 needs for the desktop's fragments.
**On the servers only:**
- `zfs` with its scrub timer, and the long-term kernel it builds against;
- `nfs-server`;
- `samba`;
- `vnstat`, `lm_sensors`.
`cron` on the anchor serves one stock file and can go. So can the entropy daemon on a modern
kernel.
**Retire, not model:** the legacy `iptables` services on the anchor. They duplicate the mesh's
filter, which is ADR 0100's ground.
## Questions this effort has to answer
1. **Software outside the official repositories.** The host's `package` shape installs from the
official repositories only. A catalogue module already declares an AUR package (the agent CLI),
which no machine could install, and the workstations carry 181 such packages between them.
| | option | for | against |
|---|---|---|---|
| P1 | ADR 0205's pinned vendored archive, per piece | exists | wrong for packages that build native code or kernel modules |
| P2 | **The build machine builds AUR packages into a package repository the mesh serves** from its artifact store. The host then installs them as packages, signed | one shape for every package; pinned, reviewed and built once | a repository to serve and a signing key to keep |
| P3 | An AUR helper on each machine, driven by the host | nothing to serve | builds on every machine, unpinned: the predecessor's `git clone` in another form |
Starting position: P2, for anything with native code. ADR 0205 stays for plain files such as a
theme.
2. **Secrets in the account's environment.** A predecessor file feeds package-registry and API tokens
to the session. ADR 0203 refuses secrets in contributed values, because they travel in the clear.
The candidate is ADR 0182's third class: a module's own process writes a mode-0600 file of
exports, from secrets the vault hands it over the bus, and the shell and the session source it.
This needs its own record.
3. **Per-machine sizing and drivers.** The swap layout, the GPU, the storage pool and the boot
loader are facts of one machine's hardware. They belong in hardware modules, or in settings
(issue 168), not in the shared ones.
4. **What a module may leave behind.** Compose is installed on both servers, unowned. The mesh
removes nothing it did not make. The choice is between an operator's one-off removal and a
server-side `absent` declaration.
5. **The hosts file.** ADR 0199 (decided on an open change, not yet merged)
gives `/etc/hosts` to one module through a seat, `node-hosts-file`, with an operator region and
three verbs. It is not built. Today the private network's foundation writes only its own block, and
the rest of each file is a predecessor's stale blocks (both servers) or the operator's development
names (both workstations). The candidate module is that seat's first holder. It takes the
private-network block as a contribution, and its operator region replaces the hand-kept lines.
6. **Mounts.** The host has no shape for a filesystem mount; ADR 0091 is about what a container
mounts. One workstation mounts a share of the home server over NFS, and a second share over SMB.
That second one fails, and its credential sits in clear in `/etc/fstab`.
| | option | for | against |
|---|---|---|---|
| M1 | **A module owns `/etc/fstab`** and other modules contribute lines | one file, as people know it | the file also carries the root and boot filesystems the installer wrote, which no module should rewrite; a slot contribution into a file that can stop a machine booting |
| M2 | **Each client module writes its own systemd mount (and automount) unit**, which is the service manager's drop-in for exactly this. The `nfs-client` or `smb-client` module declares the unit file and the service shape enables it. `/etc/fstab` stays the machine's | no new host shape, and no shared file; the unit names its own dependencies (network online, the private network) and an automount does not hang a boot when the server is away; unassigning removes the mount | a mount reads as a unit, not a line |
| M3 | A new `mount` shape in the host | the host knows what a mount is | a second way to say what M2 says |
Starting position: **M2.** The credential an SMB mount needs is a secret, written by the module's
own process from the vault, mode 0600, which is question 2's mechanism. The pair is a server
module exporting (`nfs-server`, `samba`) and a client module mounting. The client requires the
share the server provides, so the mount is resolved, not hand-typed.
7. **Two DHCP clients on one interface.** The home server runs `dhcpcd`, a DHCP *client* (no machine
runs a DHCP server), next to the network manager, which is its assigned networking module. Both
lease an address on the same interface, which therefore carries two LAN addresses. The catalogue's
`dhcpcd` module is assigned nowhere, and this unit is a leftover. The network manager is the
machine's one DHCP client, and `dhcpcd` should be disabled there.
## Security findings, independent of any module
1. A filesystem credential in clear text in a workstation's `/etc/fstab`, for a mount that is failing
anyway.
2. A predecessor CI user with passwordless sudo and docker membership on the anchor, and a
predecessor sudoers drop-in. *The user was removed on 2026-10-04; the drop-in remains.*
3. The operator account in the `root` group on one workstation.
Each is one small change. None waits for a module.
@@ -0,0 +1,169 @@
# 03 — The account's own tools: ssh, scripts, mail
Three further directions from the operator on 2026-10-04. Each is account-level, like the shell
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)).
## `~/.ssh` is one module's
*"A module owns `~/.ssh`, so it is its responsibility that every folder is set up consistently and
correctly."*
**Measured:**
- The catalogue's `ssh-client` module owns the directory (mode 0700) and one region of
`~/.ssh/config`: a `Host` block per machine of the mesh. It owns nothing else.
- On one workstation, a predecessor's header, `Include` and hand-written host block sat **above** the
mesh's region. ssh takes the first match, so the predecessor's entries were the ones in force, for
the same machines. Removed on 2026-10-04.
- On the control machine, two keys of a retired CI system were still in the operator's
`authorized_keys`, able to log in as the operator. Removed the same day.
- Permissions differ by file and by machine. Backups of the configuration lie beside it.
**Starting position:** `ssh-client` becomes the holder of everything under `~/.ssh`, classified as
ADR 0182 asks:
| path | class | how |
|---|---|---|
| `~/.ssh/`, its mode, every file's mode | owned | the directory resource, plus a check verb that reports a file with the wrong mode |
| `~/.ssh/config` | written into, the mesh's block **at the start** | the mesh's hosts win; the operator's lines after it are kept; an `Include config.d/*` line in the block |
| `~/.ssh/config.d/<module>` | owned by the contributing module | ssh's own drop-in: a work module adds its forge's host there (research 026 C1) |
| `~/.ssh/authorized_keys` | written into, the mesh's block | the operator's keys as the mesh records them, and nothing a retired system left. The operator's own lines are kept below the block |
| `~/.ssh/known_hosts` | written into, the mesh's block | every mesh machine's host key, so the first connection never asks |
| private keys | found | never read and never written by the mesh; a key the mesh should hand out comes from the vault, through the module's own process (ADR 0182, third class) |
The sshd module is the other half: the machine's side. It is already in the catalogue.
## Scripts on every machine, shared and machine-specific
*"All nodes should get some custom scripts, both node-specific and mesh-specific (shared)."*
**Measured:** the operator's script folder holds 64 entries plus 33 in its `bin/`. It is under no
version control, and exists only where it was copied. It mixes three kinds:
1. scripts belonging to a module (the desktop's watchers, lock, menus; a laptop model's brightness);
2. the operator's own tools;
3. installers that modules have replaced.
**Starting position:**
- **The operator's scripts live in a repository of their own,** registered as any application is
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)), built as archives,
unpacked into a directory the module owns under the home. `bin/` goes on `PATH` through an
environment contribution ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)),
and small functions go into the shell through a `shell` contribution
([ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)).
- **"Machine-specific" is said by assignment, never by naming a machine**
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md)). One repository
holds several modules:
- `scripts` (shared, on every machine);
- `scripts-workstation`;
- `scripts-media`;
- and so on, each assigned where it applies.
A script that belongs to a piece of software or hardware moves into that module instead. A flavor
inside one module is what [research 026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
says not to repeat.
- **A script can also be a tool.** A script with a one-line description is served by the node's
runtime, so it can be called through the mesh on any machine that has it.
- A script that needs a secret gets it through question 2's mechanism, never from a file of
environment secrets.
## The keyring
*"A keyring is also a good thing to create a module for."*
**Measured on the two workstations, which both run GNOME Keyring:**
- **On one, the keyring unlocks at login.** The login manager's PAM service includes `login`, which
carries `pam_gnome_keyring`.
- **On the other, it does not.** The PAM line is only in the screensaver's service, so at session
start the window manager runs a script that asks for the password a second time and unlocks the
keyring with it.
- **On both, the session's start script starts the daemon again** with the ssh and gpg components,
and exports the ssh agent's socket. The keyring's current release serves the ssh agent through a
separate per-user socket unit instead.
**Starting position:** a `gnome-keyring` module that holds a node seat, `node-secret-service` (the
holder of the desktop's secret service; a password manager could hold it instead). It declares:
- the package;
- its lines in the login manager's PAM file, written into, never over (ADR 0102), so login unlocks it
on every machine;
- the ssh agent's user socket, once user-scoped units ship;
- the agent's socket path as an environment contribution, which needs a machine fact for the
account's runtime directory. ADR 0203 forbids `$` in values, so `$XDG_RUNTIME_DIR` cannot be
written in one.
The second unlock prompt and the second daemon start go away.
## Mail as events
*"Ideally a mail consumer with all my mail accounts configured, so my mail is recorded in the bus."*
**Measured:**
- The predecessor polled one work mailbox every minute. It **read an access token out of the mail
client's process memory**, called a mail API with it, and raised a desktop notification per unread
message. It worked only while the mail client ran, and stopped silently when the predecessor's units
were retired.
- Two further predecessor modules served mail tools, for one provider and for IMAP.
- The mesh runs a mail server of its own for its domains.
**Not decided here; it needs an effort of its own.** The questions it would have to answer:
- **Accounts and how each authenticates:**
- IMAP with an app password;
- a provider's OAuth with a registered application;
- the mesh's own mail server, which can publish delivery itself.
An employer's tenant may forbid registering an application at all.
- **What the bus records:**
- headers and a summary as events;
- bodies and attachments in an object store the event points at;
- retention, since mail is the most personal data the mesh would hold.
- **What consumes it:** a notifier bridge to the desktop (the predecessor's notifications), search,
an agent's context.
- **Where it runs:** one long-running module, not per machine (ADR 0198).
The obvious first step is the mail server the mesh already runs.
## Power management on the laptop
*"Power management for the laptop."*
**Measured on the laptop** (a gaming model with a hybrid GPU):
- **The platform profile is driven by a vendor daemon** (`asusd`) and its CLI. The vendor CLI is
now in the official repositories; the copy installed came from elsewhere. A predecessor script
runs as a user unit and switches the profile every five seconds: quiet on battery, balanced on
mains, performance above 50 % CPU.
- **The hybrid GPU's mode** (now hybrid) is held by a second vendor daemon (`supergfxd`), which is
**not** in the official repositories. Kernel-module options for the discrete GPU's power state
and its suspend, hibernate and resume units are set by hand. Its own power daemon is masked.
- **The battery charge limit is 80 %,** set by the vendor daemon.
- **The lid and power key suspend.** The brightness key is ignored by logind and handled by the
vendor-key path. Both are logind drop-ins.
- **Memory pressure:** compressed swap in RAM (`zram`) beside a swap file and a partition;
`systemd-oomd` with drop-ins; a predecessor *memory guard* user unit that notifies before the OOM
killer acts.
- `upower` runs. There is no `power-profiles-daemon`, `tlp`, `auto-cpufreq` or `thermald`, so nothing
competes with the vendor daemon, by design.
All of it came from two predecessor modules, one of which was a laptop-model *flavor*. A desktop
received part of it (research 026/01).
**Starting position:**
- **A hardware module per machine model** (here, the laptop's model). It holds the vendor daemon and
its profile configuration, the GPU mode daemon (ADR 0205's case, or the build machine's package
repository of research 027 question 1), the discrete GPU's module options and suspend units, the
logind drop-ins, the battery charge limit, and the vendor keys and brightness. It is assigned to
the one machine of that model, and to any second one later.
- **The profile switching** moves from a polling script to the module's own long-running code
(ADR 0198). It reacts to the power-supply change event instead of polling, and its thresholds
become settings (issue 168).
- **Memory pressure is not the laptop's alone.** `zram` and `systemd-oomd` with the notifier are a
`memory-pressure` module, assigned wherever wanted. The swap layout stays the machine's (`kernel`
module, question 3).
- A **`node-power-profile`** seat (vendor daemon, or `power-profiles-daemon` on other hardware)
gives the mesh one verb, `profile`, the same on every machine that has one.
@@ -0,0 +1,124 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
- 04-ISSUES/229-a-rollout-cannot-be-followed-through-the-meshs-tools/00-report.md
- 04-ISSUES/230-a-host-that-hands-over-to-a-newer-one-loses-its-report-and-a-plan-waits-for-ever/00-report.md
- 04-ISSUES/233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
- 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md
- 02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md
- 02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md
became:
- 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
- 03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md
---
# 028 — The mesh's output channel
## What
How the mesh tells its operator what it noticed. The operator's framing: sending notifications
is **an output channel for the mesh**. The mesh already knows when a machine stops answering, when
a failure repeats and will not fix itself, when a rollout waits for ever. Today it keeps that to
itself until someone asks.
The effort looks at:
- **the seat:** one, held once for the mesh, that every other part uses to say something to the
operator;
- **the channels**, each a module: a desktop notification on the machine the operator is at,
**Telegram**, a phone push service, chat, mail and others (see [02](02-the-channels.md));
- **the routing**, by severity and by where the operator is;
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
silenced by the operator;
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
ones that failed;
- **the conversation** (widened 2026-10-06): the mesh, its modules and its agents send messages and
**asks** (a question, a choice, a value, an approval), and the operator answers or writes first, over
channels chosen by their declared **capabilities** and by the operator's **work context**. Inputs
from outside (a mail arriving) share the same envelope;
- **asks that authorise:** one layer on top, for the answers that perform an action. These are checked
by the controller, and allowed only on channels whose capabilities prove that the operator answered.
### Why this effort widened rather than a new one opened
On 2026-10-06 the operator asked for these:
- approving and rejecting through Telegram;
- a generic shape in which Telegram simply holds a seat;
- input triggers alongside output channels;
- capabilities that decide which actions may travel on which channel;
- the work context as a factor in choosing the channel;
- asks that are not about permission at all.
That could have opened a new effort. It did not, because:
- every part of it hangs on this effort's open questions: Q1 (where a channel attaches), Q4
(presence), Q7 (answering back) and Q8 (what may leave);
- ADR 0227 kept answering back open **here**, and said this effort's graduation amends to-be 45 §5;
- an answer belongs to the message or ask this seat sends. Splitting them would leave two efforts each
owning half of one conversation.
Input that is not an answer (a mail arriving, a webhook) shares the envelope and the seat shape, and
is designed here only as far as that shape. Its consumers are later work.
## Why
[Issue 187](../../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md) is the
class: *the mesh tells nobody when it stops working*. [01](01-what-the-mesh-already-knows.md)
counts it.
- 15 of the 236 issue reports say the fault was found because a person happened to look.
- 74 describe something failing silently.
On the day this effort opened, the mesh knew three things and told nobody:
- a workstation had refused every declaration for ninety minutes;
- the same workstation had been out of touch for ten minutes after an upgrade;
- one failure on the laptop had repeated thirteen times.
Every one of them was in `status`, for whoever asked.
The pieces exist. [To-be 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) already uses a
`telegram-sender` seat as its worked example of a work queue with retention. ADR 0208 made a
machine's desktop notifier a node seat with a `send` verb. What is missing is a seat that speaks
for the mesh, sources that call it, and channels that deliver.
## What it touches
- **The controller**, which would become the first source of what it already computes for `status`.
- **The node-notifier seat**, which would become one channel among several.
- **Issues 187, 229, 230 and 233**, each of which ends in "and nothing said so".
- **ADR 0210**, because a channel extends the output seat through a contribution, and therefore
depends on it.
## Documents
1. [What the mesh already knows](01-what-the-mesh-already-knows.md): the evidence, and the events
that exist.
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
watcher, what may leave the mesh.
4. [Telegram, as the first holder](04-telegram-as-the-first-holder.md): making the bot, the bot
API's limits and semantics, what Telegram sees, and ten defects in the built code.
5. [The other holders, on the same axes](05-the-other-holders-on-the-same-axes.md): ntfy, Matrix,
Pushover, Gotify, mail, Signal, SMS and a dead-man service, as away channel and as the watcher's
path.
6. [A conversation with the operator](06-a-conversation-with-the-operator.md): messages, asks and
operator messages; asks' kinds and life; the kinded benches `channel` and `intake`; the capability
vocabulary; agents as participants; the migration.
7. [The work context, and the desk](07-the-work-context-and-the-desk.md): the signals, the routing
by context and its escalation, presence kept inside the mesh, and the desk as a full participant
(notification actions, the launcher's prompt).
8. [Asks that authorise](08-asks-that-authorise.md): the actions that need a person, the trust
capabilities, the three proofs and the three tiers, the controller's checks, why the desk needs a
factor, and what a compromise can reach.
9. [A proposed decision](09-a-proposed-decision.md): the recommendation, the operator's steps, the
tables, and a record ready for graduation.
@@ -0,0 +1,60 @@
# 01 — What the mesh already knows, and who hears it
## The count
Over the 236 issue reports in `04-ISSUES/`, on the day this effort opened:
- **15** say, in some wording, that a person found the fault by looking: "nobody was told",
"nothing logged / said / alerted / emitted", "a person asked", "found by a person". Five of them
are still open.
- **74** describe something that failed silently.
The search was a word match over the reports' text, so it undercounts reports that tell the same
story in other words. It never overcounts by much: each of the fifteen was read.
The fifteen fall into three groups:
- **The mesh knew, and kept it in a query.** The fault was in `status`, `plans` or a node's record,
for whoever asked. Examples: a rollout waiting for ever (230), a machine refusing every declaration
(233), a setting that cannot work stored and stopping the node (096).
- **The fault was in a log nothing reads.** Examples: the bus refusing the controller's publishes
(187), a dropped report (187, 230).
- **The fault was invisible to the mesh itself.** Examples: a resolver outside the mesh closed by its
filter (198), a port narrowed without saying (086).
Only the first group is a matter of telling: the fact exists, and only delivery is missing. The
other two need a source first. This effort is about the first, and about giving the other two a
place to say something once they can.
## What the controller computes and does not say
Read from `status` and `node show` on the day this effort opened. Each line is a fact the controller
already holds:
| fact | where it is today | example that day |
|---|---|---|
| a machine is out of touch | `node show`: "last heard from — out of touch 10m" | a workstation after an upgrade |
| a machine refused its declaration | `status`: "refused" with the reason | the same workstation, for 90 minutes |
| a failure repeats and will not fix itself | `status`: "stuck: the same failure N times since …" | 13 times on the laptop |
| machines run different hosts | `status`: the version table | after a host release |
| something runs that the mesh did not write | `node show`: strays | 16 containers on one machine |
| a filter rule the mesh did not write | `status` | one machine |
| a plan is waiting | `plans` | issue 230: "for 0s", for ever |
| an assignment does not compose | the `assign` answer only | issue 235 |
None of these is published. The bus carries a seat's own events (a build's outcome), a module's
declared events, tool calls and declarations. It carries no event for any line above.
## What exists to deliver with
- **A machine's desktop:** the `node-notifier` seat (ADR 0208), held on the laptop. Its `send` verb
shows a notification, and `history` lists them. It was used through the console the day this effort
opened.
- **Mail:** a mail module provides `smtp` to the mesh.
- **Chat:** a Matrix server runs as a module on the home server.
- **Home automation:** a home-automation module runs there too, and its phone app can receive pushes.
- **A seat shape for exactly this:** to-be 32 §5 uses `telegram-sender` (`accepts: send`,
`retain 7d`, `emits: delivered, failed`, `serves: status`) as its worked example. A seat's stream
exists from registration, so work queues until a holder appears.
No module sends to Telegram, a phone push service or SMS today.
@@ -0,0 +1,116 @@
# 02 — The channels
Each channel is a candidate module that delivers what the output seat hands it. They are weighed on
the same questions:
- **Reach:** does it reach the operator away from the machines (phone), or only at a desk?
- **Off-mesh:** does it still work when the mesh's own parts (the bus, the controller, the control
node's network) are what failed?
- **Two-way:** can the operator answer through it: acknowledge, silence, ask?
- **Where the words go:** does the message leave the operator's own machines, and to whom?
- **What it costs to hold:** a secret, a server, an account, money.
## The candidates
### Telegram (required by the operator)
A bot created with Telegram's bot service sends to one chat: the operator's own, or a group.
- **Reach:** the phone and every desktop, with push.
- **Off-mesh:** sending needs only outbound HTTPS from any machine. No inbound port, no server of the
mesh's own. A second machine can hold the same bot token and send when the first is the one that
failed.
- **Two-way:** yes. Inline buttons on a message (acknowledge, silence for an hour) and commands to
the bot, read by long polling over outbound HTTPS. This makes Telegram the strongest candidate for
answering, and the riskiest (see [03](03-open-questions.md), Q7).
- **Where the words go:** to Telegram's servers. Bot chats are not end-to-end encrypted. What a
message may contain is therefore a rule this effort must set.
- **Cost:** one secret (the bot token) and the chat's id. Free. Rate limits are far above what an
operator should receive.
- **Formatting:** short text with a little markup, buttons and links. Enough for a subject, a
machine role, a severity and one line of why.
### The desktop notifier (exists)
The `node-notifier` seat's `send` verb on the machine the operator is at.
- **Reach:** only at that machine, only while a session is up.
- **Off-mesh:** no. It is reached through the mesh's tools.
- **Two-way:** dunst has actions, which a click can answer, but nothing reads them back yet.
- **Where the words go:** nowhere; it is local.
- **Cost:** none.
- **Its place:** the gentlest channel, for a warning while the operator is at a desk. "At a desk" is
itself a question: an unlocked session on a machine with recent input.
### ntfy (or Gotify): a self-hosted phone push
A small server publishes topics; its phone app subscribes.
- **Reach:** the phone, with push.
- **Off-mesh:** only if the server runs outside what failed. On the control node it fails with it.
- **Two-way:** action buttons can call a URL, which is an inbound path to design.
- **Where the words go:** stays on the operator's machines when self-hosted. ntfy's iOS push passes
through an upstream relay unless configured otherwise.
- **Cost:** a module with a container and a routed name; a token per topic.
### Matrix (a server exists as a module)
A bot account posts to a room the operator is in.
- **Reach:** phone and desktop through any Matrix client.
- **Off-mesh:** no, the server is one of the mesh's modules.
- **Two-way:** yes, by messages to the bot.
- **Where the words go:** stays on the operator's server, end-to-end encrypted if the bot supports it.
- **Cost:** a bot account, a secret.
### Mail (a mail module provides `smtp`)
- **Reach:** everywhere, without urgency.
- **Off-mesh:** no, if the mesh's own mail server sends. Yes, through an outside relay.
- **Two-way:** no, not usefully.
- **Its place:** the record and the digest: a daily summary of what was said and resolved, and the
fallback when nothing else acknowledged.
### The home-automation companion app (a module exists)
Its phone app takes pushes and actionable notifications, and the home has lights and speakers.
- **Reach:** the phone, and the house itself: a light that turns a colour.
- **Off-mesh:** no, the home server is a node.
- **Its place:** a playful critical channel, not a primary one.
### The bar on the desktop
An `i3status-rust` block showing the count of open messages, red while one is critical.
- **Reach:** the desk only, and silent.
- **Its place:** the ambient state. Nothing interrupts the operator, and they always see whether
something is open.
### The console (an agent session)
A message the next agent session opens with ("two things happened while you were away").
- **Its place:** context for the agent working on the mesh rather than an alert. It falls out of the
message store if the store is queryable.
### Others, noted and not pursued now
- **SMS or a voice call** through a paid gateway. It is the only channel that works with no data
connection, and the only one that costs per message.
- **Signal**, through an unofficial client: no bot API, and a registered number.
- **Discord or Slack** webhooks: the words go to a third party, as with Telegram, without its two-way
strength.
- **Pushover:** paid, closed, and a phone push service much like ntfy.
- **An external dead-man service** (a heartbeat URL that alerts when pings stop). It belongs to
[03](03-open-questions.md), Q6, as the watcher's watcher rather than as a channel.
## A first reading
- **Telegram** is the primary phone channel, and the only candidate that is cheap, off-mesh capable
and two-way at once.
- **The desktop notifier** is for the desk.
- **The bar** shows the ambient state.
- **Mail** carries the digest and the record.
- **ntfy and Matrix** are self-hosted alternatives for an operator who keeps words off third parties.
The seat must make that a choice, not a rewrite.
@@ -0,0 +1,128 @@
# 03 — Open questions
Each question names the options seen so far. None is decided here. Q1 and Q7 are taken further in
[06](06-a-conversation-with-the-operator.md) and [08](08-asks-that-authorise.md).
## Q1. The seat
**What speaks for the mesh to its operator?**
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
waits until a holder appears.
- It **emits** `delivered`, `acknowledged` and `resolved`.
- It **serves** `open` (what is unresolved now) and `history`.
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
learn every channel. This is the inversion ADR 0126 exists to prevent.
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
own or part of the controller is open. A module keeps the controller small. The controller already
holds most of the facts.
## Q2. What a message is
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
the tool that shows more, and a **key** that makes it the same message the next time it is said.
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
Every extra level is a routing rule somebody must keep right.
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
one message, still open, not sixty.
## Q3. The life of a message
open → (acknowledged) → resolved.
- **Deduplicate** by key while open.
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
`open` answers after a restart.
## Q4. Routing and presence
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
operator is at one, otherwise to the phone, otherwise to the digest.
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
seats' holders. Nothing more invasive.
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
operator chooses.
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
network outage where every node is out of touch) arrives as one message naming many.
## Q5. The sources
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
today:
- a machine out of touch;
- a declaration refused;
- a stuck failure;
- a plan late (once issue 230 gives a wait an age);
- an assignment that does not compose (issue 235);
- a host version split.
**How each becomes an event:**
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
- **b.** The controller calls `notify` itself.
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
facts, and the holder decides what is worth a message. With b, the controller decides severity.
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
(a backup failed, a certificate is close to expiry), with the same message shape.
## Q6. The watcher's watcher
When the controller, the bus or the control node is what failed, nothing above runs. Options:
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
heartbeat service), which alerts the operator by its own means when pings stop.
- **A second holder of the Telegram channel on another machine** that sends directly, without the
bus, when it stops hearing the controller for longer than a bound.
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
needs the channel's secret on every machine, a cost to weigh.
The first is the cheapest and the only one that also covers "the whole house is offline".
## Q7. Answering back
Telegram, and Matrix, can carry the operator's answers.
- **Acknowledge and silence** are safe: they change only the message's state.
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
controller. If it ever comes, it needs:
- its own record;
- a narrow verb set;
- a check that the answer came from the operator's own account and chat;
- and probably a confirmation step.
The first version should probably answer with acknowledge and silence only.
## Q8. What may leave the mesh
Telegram, and any third-party channel, carries the words to someone else's servers. A message
names a machine, a module and a reason, which is operational detail.
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
- **Is a self-hosted channel required for anything above a severity?**
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
## Q9. How it is checked
A rule this effort produces must say how it is verified. Candidates:
- a message said twice with one key is one message;
- a resolved source resolves its message;
- a critical message reaches every channel within a bound;
- a message containing an address or a secret is refused;
- the dead-man signal fires when the holder is stopped.
Each is a test of the holder, or a live drill: stop a machine's host and time the message.
@@ -0,0 +1,222 @@
# 04 — Telegram, as the first holder of a channel
Telegram was the operator's first required channel ([02](02-the-channels.md)) and it is built:
the output seat's holder carries a Telegram client, and so does the watcher's watcher (to-be 45 §5).
Neither is configured, because no bot exists yet. This document is what the operator needs to make one,
what the mesh's use of the bot API must respect, and what the built code gets wrong against it.
[06](06-a-conversation-with-the-operator.md) makes Telegram one holder of a generic channel seat rather
than the subject of the design. Everything here stays true under that shape: it is the first holder's
analysis.
Facts are as of 2026-10-06, Bot API 10.3 (2026-08-24). Sources are listed at the end.
## What the mesh uses from Telegram
Two programs send, and neither reads anything back yet:
- **The output seat's holder**, on the control node. It sends a message when a condition is raised,
says it again as a reminder, and edits the first message in place when the condition clears.
- **The watcher's watcher**, on a machine that is not the control node. It sends straight to the bot
API over HTTPS when the controller's self-check or the bus has been silent past its bound.
Each holds a **bot token** as its own secret, issued outside the mesh (ADR 0228, `issued-by: outside`),
and a **chat id** as a setting. Each sends plain text: no `parse_mode`, so no markup to escape and none
to inject.
## Making the bot
Telegram has no developer console. A bot is made by talking to Telegram's own bot, BotFather, from an
ordinary Telegram account.
- **An account is required, and an account needs a phone number.** There is no other sign-up. The
number can be a virtual one bought on Telegram's own marketplace, at a price that makes it
irrelevant here.
- **`/newbot`** asks for a display name and a username. The username is 5–32 characters of Latin
letters, digits and underscores, must end in `bot`, and cannot be changed later.
- BotFather answers with the **token**: digits, a colon, then a key. Anyone holding it controls the bot.
- **`/token`** issues a new token for the bot. The old one stops working at once. This is the rotation
path, and it is the only one.
- **`/setjoingroups` → Disable** stops anyone adding the bot to a group. The mesh's bot talks to one
person; a group is only a way for someone else to see what it says.
- **Privacy mode** (`/setprivacy`) governs what a bot sees **in groups**: with it on, only commands
meant for it, replies to it and service messages. In a private chat a bot sees everything the person
writes. With groups disabled, privacy mode does not matter; leave it on.
### A bot cannot speak first
A bot cannot open a conversation. Until the person presses **Start** in the bot's chat, every send to
them fails with a "Forbidden" error. So the operator presses Start once, on each bot.
### Finding the chat id, safely
In a private chat the chat id equals the person's user id. There are two ways to learn it:
- **Read `getUpdates` once by hand.** After pressing Start, a call to `getUpdates` returns the `/start`
message with the chat's id. It works today. Its two weaknesses: the token appears in a command line
(and so in a shell's history) unless read from a file, and it trusts that the `/start` it sees is the
operator's. A bot's username is public, and anyone who finds it can press Start too.
- **A linking verb with a one-time code.** The holder makes a short code and answers with a deep link
(`https://t.me/<bot>?start=<code>`). The operator opens it on the phone; Telegram sends `/start <code>`.
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
token to anyone. It needs the holder to read updates, which approvals need anyway
([08](08-asks-that-authorise.md)).
The second is the one to build. The first is the stop-gap until it exists.
### One bot or two
The holder and the watcher each have their own secret. They can hold the same token or two.
**Two bots are better:**
- Revoking one does not silence the other. The watcher exists for the day the rest is broken, and
that day must not also be the day its token was rotated away.
- The phone shows which program spoke.
- **Only one program may read a bot's updates.** Two concurrent `getUpdates` callers on one token make
Telegram answer the older with HTTP 409, "terminated by other getUpdates request". The moment the
holder reads answers, the watcher could no longer share its token with anything that reads.
## The bot API, as the mesh uses it
### Limits
- **Rate.** Telegram's FAQ: "In a single chat, avoid sending more than one message per second." In a
group, 20 messages a minute. Broadcast across chats: about 30 a second. The holder's own cap is 20 an
hour, so the limit is never near.
- **Over the limit** the API answers HTTP 429 with `parameters.retry_after`, the seconds to wait
before the request may be repeated. Repeating early prolongs the wait.
- **Length.** A text message is at most 4096 characters after entity parsing. Longer is refused with
HTTP 400, not cut.
- **Callback data** on a button is 1–64 bytes ([08](08-asks-that-authorise.md)).
### Editing
- **`editMessageText`** replaces a sent message's text. For an ordinary bot message there is no time
limit. The 48-hour limit applies only to business messages, and deletion has its own 48-hour limit.
- **An edit notifies nobody.** No sound, no banner, and the message stays where it was in the chat's
history. This is why a clearing is cheap to say by edit. It is also why an edit must never be the
only way something **new** is said.
- **An identical edit is an error:** HTTP 400, "message is not modified". It is harmless and must be
read as success, not as a failure to fall back from.
- **A deleted message** answers "message to edit not found". Falling back to a new message is right
then.
### Loudness
Telegram has **no message priority**. The only lever is **`disable_notification`**: the message
arrives without sound. It cannot break through the phone's do-not-disturb, so an urgent message at
night is as quiet as the phone is set to be. Per-chat notification settings on the phone (a custom
sound, an exception to do-not-disturb) are the operator's, not the mesh's.
### Formatting
With no `parse_mode` the text is shown as written, and nothing in a message can be read as markup.
That is the right default for words that pass a content rule rather than a template. If markup is ever
wanted, `MarkdownV2` needs every reserved character escaped and fails the whole send on one miss, so
plain text or `HTML` with escaping are the safer options.
`disable_web_page_preview` was **deprecated in Bot API 7.0** in favour of
`link_preview_options: {is_disabled: true}`. It still works, but the mesh's messages carry no links
(the content rule refuses URLs), so the parameter can simply be dropped.
### Answering back
Answers reach a bot two ways:
- **Long polling with `getUpdates`**, over outbound HTTPS. Updates wait at Telegram for at most
24 hours.
- **A webhook** (`setWebhook`), which Telegram calls over HTTPS on port 443, 80, 88 or 8443. It may
carry a secret header (`X-Telegram-Bot-Api-Secret-Token`) that proves the call came from the webhook
that was set.
Only one of the two at a time. [08](08-asks-that-authorise.md) chooses between them.
## What Telegram sees
- **Everything in the message.** A bot chat is a "cloud chat": encrypted between the phone and
Telegram, and between Telegram and the bot API caller, and readable by Telegram. Bots cannot take
part in Telegram's end-to-end "secret chats".
- **That is what the content rule is for.** The holder refuses any message carrying an address, a
path or a secret's shape (to-be 45 §5), so what Telegram stores is roles, words and condition keys.
- **Who the operator is.** The account's phone number, and the addresses the phone and the sending
machines connect from. Since September 2024 Telegram's privacy policy says it may disclose a user's
IP address and phone number to judicial authorities on a valid order.
- **Machine names.** A condition key names the machine it is about, and so does the watcher's message
("told by mesh-watcher on …"). The content rule refuses host names with a top-level domain, not bare
machine names. To-be 45 says a message's subject is "a machine's role". Whether a bare machine name
may leave is a decision this effort has not taken; today it does.
## When Telegram is unreachable
- **A send fails at the transport** (no DNS, no connection, timeout). The holder keeps what it held
and tries again every minute. The watcher keeps what it owes and tries again at its next tick.
Neither loses a message while it runs.
- **A send fails because Telegram refuses** (400 or 403). It is permanent for that message. The holder
today treats it like a transport failure and tries again every minute, for ever (see the defects).
- **Telegram being down is invisible to Telegram.** The holder's status says the channel is failing.
The operator sees that only through another channel or by asking. This is what a second holder of a
different kind is for ([05](05-the-other-holders-on-the-same-axes.md)).
- **Telegram is blocked** in some countries and on some networks. An operator travelling should know
the mesh's phone channel may be one of them.
## Cost
- **Free.** No per-message charge. Telegram's paid broadcasts (above 30 messages a second) are far
out of range.
- **One account**, which the operator very likely already has.
- **Two secrets**, one token per bot, both `issued-by: outside`.
## The built code, checked against this
Read from the code repository's main branch on 2026-10-06: the holder's `telegram.go`, `outbox.go`,
`holder.go`, `content.go`, and the watcher's `telegram.go` and `watcher.go`. The two Telegram clients
are copies of each other, kept apart on purpose so the watcher depends on nothing it watches.
What is right:
- **Plain text**, with no `parse_mode`.
- **The token is kept out of every error.** The client rebuilds transport errors from their kind,
because the URL carries the token. It also strips the token from the API's own description.
- **Token and chat id are re-read at each send**, so accepting the secret or changing the setting needs
no restart.
- **A token's shape is checked.** A random value the mesh minted for an un-accepted secret is named as
that, not sent to Telegram to be refused.
- **A missing edit falls back to a new message.**
- **The holder caps itself** at 20 messages an hour and folds bursts into digests, far inside
Telegram's limits.
### Defects
| # | Where | What | Effect | Weight |
|---|---|---|---|---|
| D1 | holder: `telegram.go`, `outbox.go` | Nothing bounds a message to 4096 characters. A long summary, or a digest of long titles, is refused with 400. `failed` keeps the whole batch and retries it every minute. | One oversized message wedges the Telegram channel: everything queued behind it waits for ever. | high |
| D2 | holder: `holder.go` (`h.edit(old, "reopened", false)`) | A condition that clears and is raised again within ten minutes is said by **editing** the first message. On Telegram an edit notifies nobody. | A reopened urgent condition reaches the phone **silently**, high up in the chat's history. | high |
| D3 | holder: `outbox.go` (`r.Sent[name]` keeps only the first id) | Reminders and escalations are new messages, but clearing edits only the first. | The newest thing on the phone still says "STILL OPEN" or "NOW URGENT" after the condition cleared. The "CLEARED" is a silent edit, out of sight. | medium |
| D4 | both: `telegram.go` | `Message.Quiet` and `Message.Urgent` are ignored. `disable_notification` is never set. | A clearing, or a warning, rings as loudly as an urgent message. Telegram's only loudness lever is unused. | medium |
| D5 | both: `telegram.go` (`call`) | HTTP 429's `parameters.retry_after` is not read. The retry is a flat minute. | Harmless at the holder's cap. Under a real flood wait, repeating early prolongs it. | low |
| D6 | holder: `telegram.go`, `outbox.go` | Every refusal (400, 403) is retried like a transport failure. "message is not modified" on an edit is read as a failed edit, and a new message is sent instead. | Permanent errors loop every minute in the log. A no-op edit becomes a duplicate message. | low |
| D7 | both: `telegram.go` | `disable_web_page_preview` is deprecated since Bot API 7.0. | Works today. Moot, since no message carries a link. Drop it. | low |
| D8 | both: `telegram.go` (`call`) | The `json.Marshal` error is discarded. A non-numeric `message_id` (`json.Number`) makes the body empty. | A confusing refusal from Telegram instead of a local error. Ids come from Telegram, so it is unlikely. | low |
| D9 | watcher: `watcher.go` (`Tick`) | A "silent" message that could not be sent is overwritten by the "CLEARED" message when the signal returns. | The operator can receive "heard again" for a silence they were never told of. Better to say both, or one line saying it was silent for N minutes and is back. | low |
| D10 | both | `Ready()` is satisfied by a token and a chat id. It does not know whether the operator pressed Start, or whether the bot was blocked (403). | Status says "ready" until the first send fails. The watcher's own test verb is the only proof. A `getChat` check at status time would say it. | low |
D1 and D2 matter before the channel is configured. D1 can silence the channel. D2 silences exactly the
case (a flapping urgent condition) the operator most needs to hear.
## Sources
- Telegram, *Bots FAQ*: rate limits, paid broadcasts. https://core.telegram.org/bots/faq
- Telegram, *Bot API* (version 10.3, recent changes, `getUpdates` retention, `ResponseParameters`,
`setWebhook`, `link_preview_options`). https://core.telegram.org/bots/api
- Telegram, *Bot features*: BotFather, `/newbot`, `/token`, `/setprivacy`, deep linking.
https://core.telegram.org/bots/features
- `link_preview_options` replacing `disable_web_page_preview` (Bot API 7.0); the removal of the old
argument in python-telegram-bot v22. https://docs.python-telegram-bot.org/en/v22.0/telegram.ext.defaults.html
- "message is not modified" and "message to edit not found" in practice:
https://github.com/tdlib/telegram-bot-api/issues/400
- 409 "terminated by other getUpdates request":
https://community.home-assistant.io/t/help-on-telegram-extension-error-while-getting-updates-conflict-terminated-by-other-getupdates-request-make-sure-that-only-one-bot-instance-is-running-409/177544
- Telegram privacy policy change, September 2024:
https://www.bleepingcomputer.com/news/security/telegram-now-shares-users-ip-and-phone-number-on-legal-requests/
- Bots and secret chats; cloud-chat encryption:
https://www.kaspersky.com/blog/telegram-privacy-security/38444/
- Phone number required; anonymous numbers: https://en.wikipedia.org/wiki/Telegram_(software)
@@ -0,0 +1,186 @@
# 05 — The other holders, on the same axes
Every candidate is judged as a holder of the channel and intake seats in
[06](06-a-conversation-with-the-operator.md): which capabilities it can honestly declare
(the vocabulary is defined there), and what it needs. [02](02-the-channels.md) weighed the same
candidates before any of this was measured. This document replaces its reading of them with facts as
of 2026-10-06, and adds the question 02 could not ask: **can the operator answer through it, and
authorise an action through it?** ([08](08-asks-that-authorise.md)).
Two roles are judged separately, because they want different things:
- **The away channel:** the mesh's urgent messages and its asks, wherever the operator is.
- **The watcher's path:** the message that the mesh itself has gone silent. It must not depend on the
control node, the bus or the controller. The mesh observed has its controller, its bus and its mail
server on the anchor (the control node). Its Matrix server is on the home-server, behind a household
connection.
## The candidates
### ntfy
A small push server. Topics are published to over HTTP; the phone app subscribes.
- **Self-hosted vs the public server.** Self-hosted keeps the words on the operator's machines.
The public `ntfy.sh` takes no sign-up. Its free tier allows 250 messages a day **per IP address**,
shared with whoever else sends from that address. Paid tiers (from about $5–6 a month) give
reserved topics and higher quotas. An unreserved topic on the public server is readable by anyone
who guesses its name.
- **Phone delivery.**
- **Android:** through Google's FCM from the public server, or through the app's own long-lived
connection to a self-hosted server ("instant delivery"), which costs battery.
- **iOS:** cannot be reached by a self-hosted server alone. The server must name an upstream
(`upstream-base-url`, normally `ntfy.sh`), which receives a poll request carrying only a message
id and a hash of the topic, and has Apple wake the phone. The words do not pass through the
upstream. The dependency does.
- **Loudness:** five priorities. The highest gives "really long vibration bursts" and a pop-over on
Android.
- **Answers:** up to three action buttons. An `http` action makes **the phone** send a request,
which needs a route from the phone to the mesh and a credential carried inside the notification.
Nothing tells the server **who** tapped, only that someone holding the notification did. No free-text
reply.
- **As the watcher's path:** self-hosted, it fails with the machine it runs on. The public server
works, at the cost of a guessable topic or a subscription.
### Matrix (a homeserver is already one of the mesh's modules)
The module runs Conduit and Element Web, on the home-server.
- **Reach:** any Matrix client on the phone. Push goes from the homeserver to the client's **push
gateway**: for the stock Element apps, Element's gateway at matrix.org, which hands it to Apple or
Google. A self-hosted gateway needs a self-built app. UnifiedPush (via ntfy) is an option on Android.
- **Push support in Conduit has lagged.** Its own documentation long listed mobile push as missing,
and forks have since reworked pushers. Whether the running version pushes reliably is **unverified**
and must be measured before Matrix is relied on for anything urgent.
- **Answers:** free text, and reactions (`m.reaction` annotations) as one-tap choices. Clients add
emoji variation selectors, which must be normalised before a reaction is read as a choice.
- **Who answered:** the sender's Matrix id is authenticated **by the homeserver**, which the mesh
runs. That is strong for an account on the mesh's own server, and only as strong as that server.
- **Privacy:** end-to-end encrypted if the bot supports it. Otherwise readable by the homeserver,
which is the mesh's own.
- **As the watcher's path:** it survives the control node going down. It does not survive the home's
connection going down, and its phone push still depends on matrix.org's gateway.
- **Cost:** a bot account and its secret. No third party for the words.
### Pushover
A paid push service with a stable API.
- **Cost:** $4.99 one-time per platform after a 30-day trial. 10,000 messages a month per
application.
- **Limits:** 1024 characters, a 250-character title.
- **Loudness:** the strongest of any candidate.
- Priority 1 bypasses the user's quiet hours.
- Priority 2 ("emergency") repeats every `retry` seconds (at least 30) until acknowledged or until
`expire` (at most three hours). It returns a **receipt** that can be polled outbound to learn
whether, and when, it was acknowledged.
- **Answers:** acknowledgement only. No buttons, no reply.
- **As the watcher's path:** yes. It is outbound HTTPS from any machine, to a third party.
- **Where the words go:** to Pushover.
### Gotify
Self-hosted, Android only. Delivers over a WebSocket the app keeps open. There is no official iOS app,
and Apple's restrictions make a self-hosted iOS push impossible without a relay. It has no answer path
beyond opening the app. **Not pursued:** it covers less than ntfy and nothing ntfy does not.
### Mail through an outside provider
- **The mesh's own mail server is on the control node,** so it fails with it.
- **An outside relay** (an SMTP account at a mail provider) reaches the operator from any machine.
- **Urgency:** none. Mail is the digest and the record.
- **Answers:** a reply, slowly. **Who answered is weak:** a From line can be forged, and checking
DKIM only proves the operator's provider sent it.
- **Mail as an intake** (a new mail arriving) is a trigger in its own right
([06](06-a-conversation-with-the-operator.md)), whatever its weakness as a channel for asks that authorise.
### Signal, through `signal-cli`
- **An unofficial client,** and it needs its own phone number, registered with a captcha.
- **It must be kept current.** Signal's own clients expire after three months, and the server then
changes incompatibly. In March 2026 Signal began unregistering accounts whose client lacked a new
protocol feature, and every `signal-cli` account registered before that date was dropped.
- **Privacy:** end-to-end encrypted. Sender identity is strong (Signal's identity keys). Reactions
and replies both work.
- **Weight:** a phone number and a maintenance burden with a hard failure mode. It is the right
choice only for an operator who requires end-to-end encryption on the phone and accepts that burden.
### SMS through a paid gateway
The only candidate that reaches a phone with **no data connection**.
- **Cost:** per message, with an account at a gateway.
- **Privacy:** no encryption. Sender identity on replies is spoofable.
- **Its place:** the last resort of an outside dead-man service (below), which offers SMS and phone
calls on paid plans, rather than a channel of the mesh's own.
### An outside dead-man service
A service the mesh **pings**, which alerts by its own means when the pings stop. It is not a channel
of the mesh: it is the one thing that still speaks when **every** machine, or the home's connection
and the anchor together, are gone. [03](03-open-questions.md) Q6 named it the cheapest answer that
also covers "the whole house is offline".
- **Healthchecks.io** (also self-hostable, which defeats the point here) monitors 20 checks free,
without a card.
- It notifies through Telegram, Signal, Matrix, ntfy, Pushover, mail and others.
- Paid plans add SMS, WhatsApp and phone-call credits.
## The table
Capabilities are those of [06](06-a-conversation-with-the-operator.md). ✓ declared honestly,
— not, ~ conditional (the note says on what).
| Holder | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | code-factor | private | off the control node | cost |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Telegram | ✓ | — (do-not-disturb wins) | ✓ | ✓ | ✓ | ✓ | ✓ (user id) | ✓ | ✓ (code as a reply) | — | ~ (a holder on another machine) | free |
| desktop notifier | — | ~ (critical urgency) | ✓ | ✓ | ~ (actions, read by nobody) | — | — (any program of the account) | ✓ | — | ✓ | — | none |
| ntfy, self-hosted | ✓ | ✓ (priority 5) | ✓ | — | ~ (http action, phone → mesh) | — | — | ✓ | — | ✓ (iOS: relay sees ids) | — | a module |
| ntfy.sh | ✓ | ✓ | ✓ | — | ~ | — | — | ✓ | — | — | ✓ | free (250/day/IP) or ~$5/mo |
| Matrix (own server) | ~ (push unverified) | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ (own homeserver) | ✓ | ✓ | ~ (E2E if the bot does it) | ~ (home-server, not anchor) | a bot account |
| Pushover | ✓ | ✓✓ (emergency, repeats) | ✓ | — | ~ (acknowledge only) | — | ✓ (for acknowledge) | ✓ | — | — | ✓ | $4.99 once |
| mail, outside relay | ✓ (slow) | — | ✓ | — | — | ✓ | — (forgeable) | ✓ | ~ (code in a reply) | — | ✓ | an account |
| Signal (`signal-cli`) | ✓ | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | a number + upkeep |
| SMS gateway | ✓ (no data needed) | ✓ | — | — | — | ~ | — | ✓ | — | — | ✓ | per message |
## Reading it
**For the away channel and for asks,** Telegram is the only candidate that is all of these at
once: free, on both phone platforms, without a server of the mesh's own, and able to carry every tier
of authorising ask ([08](08-asks-that-authorise.md)), including a code as a second proof. Its price is that
Telegram reads the words, which is what the content rule is for.
- **Matrix** is the self-hosted equivalent for asks. It waits on a measurement of Conduit's push.
- **Signal** is the end-to-end-encrypted equivalent, at a maintenance cost that has already broken
every installation once this year.
**For waking the operator,** Pushover's emergency priority is the only thing that repeats until
acknowledged and gets through quiet hours. Telegram cannot. It is a reasonable **second** away holder
for urgent conditions only, if the operator wants to be woken. It cannot carry an answer beyond
"acknowledged".
**For the watcher's path,** the requirement is independence from what it watches:
- **Telegram, sent directly** from a machine that is not the control node, with its own bot, meets it
(to-be 45 §5).
- **ntfy self-hosted does not.**
- **Matrix only half meets it** (it is on the home-server, and its push goes through matrix.org).
- **What none of them covers** is the watcher's own machine, or the home's connection, going down
together with the anchor. Only an outside dead-man service covers that.
## Sources
- ntfy, *Configuration* (iOS upstream relay, FCM, access control): https://docs.ntfy.sh/config/
- ntfy, *Publishing* (priorities, actions, `http` action, message size): https://docs.ntfy.sh/publish/
- ntfy.sh pricing: https://ntfy.sh/#pricing. Its free-tier rate limit is per IP:
https://github.com/binwiederhier/ntfy/issues/1963
- Pushover API (length, quota, priorities, emergency retry/expire, receipts): https://pushover.net/api
- Pushover pricing: https://pushover.net/pricing
- Gotify, platform support: https://play.google.com/store/apps/details?id=com.github.gotify and
https://guancyxx.cn/en/blog/ntfy-vs-gotify-vs-nostr
- Element push gateway (Sygnal at matrix.org):
https://github.com/vector-im/element-android/blob/develop/docs/notifications.md
- Matrix reactions (`m.annotation`): https://github.com/uhoreg/matrix-doc/blob/aggregations-reactions/proposals/2677-reactions.md
- Conduit changelog: https://conduit.rs/changelog/
- signal-cli, registration with a captcha: https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha
- signal-cli, unregistration of outdated clients in 2026: https://github.com/AsamK/signal-cli/issues/1993
- Healthchecks.io pricing: https://healthchecks.io/pricing/. Its Telegram integration:
https://healthchecks.io/integrations/telegram/
@@ -0,0 +1,332 @@
# 06 — A conversation with the operator
The operator's directions, 2026-10-06, in substance:
- **Telegram is one output channel among many to come.** The setup must be generic, and Telegram
simply fulfils a seat.
- **The same holds for input:** a new mail, a new message from the operator.
- **Each channel has capabilities.**
- **Approval is only an example.** An agent may just as well want to ask a simple question, and "it
doesn't have to be permission related".
So the core of this effort is not a notifier and not an approval path. It is **a conversation with
the operator, held over channels**:
- the mesh, its modules and its agents **say** things and **ask** things;
- the operator **answers**, or **writes first**;
- each exchange goes over whichever channel is right for its needs and for where the operator is.
This document is that general model. [07](07-the-work-context-and-the-desk.md) is how the work context
chooses the channel. [08](08-asks-that-authorise.md) is one layer on top: asks whose answer performs an
action, and the checks that makes necessary. [04](04-telegram-as-the-first-holder.md) and
[05](05-the-other-holders-on-the-same-axes.md) are the first holders.
## What exists, and how it is shaped
Read from the catalogue's main branch on 2026-10-06.
- **The output seat's holder is one module doing three jobs.** It claims `operator-channel` (mesh
scope, serving `open`, `history` and `notify`). It consumes the controller's three condition events.
It holds the Telegram bot token as its own secret. It reaches the desktop through the `node-notifier`
seat's `send`.
- The router, the Telegram client and the desktop adapter are one process.
- `notify` is a served verb, not the work queue to-be 32 §3 sketched.
- **The desktop notifier** is the node seat `node-notifier`, held by the dunst module on each
graphical machine.
- **The watcher's watcher** has its own Telegram client and token, and is not assigned yet.
- **Nothing reads anything back.** The operator speaks to the mesh only through an agent session or a
shell.
## The three things said
- **A message:** the mesh tells the operator something. A condition raised, a reminder, a clearing,
a notice from a module. It expects no answer. It may be edited later (a clearing).
- **An ask:** someone wants the operator's input, of a declared kind. The answer goes back to whoever
asked.
- **An operator message:** the operator writes first. It is a message to an agent, a note to the mesh,
or an answer to an ask written in the thread instead of tapped.
Inputs from outside that are not the operator (a mail arriving, a webhook) are the same kind of
envelope as an operator message, with a sender that is not the operator. This effort designs their
shape only. Their consumers are later work.
## Asks
### Kinds
| Kind | The operator gives | Required capabilities of the channel |
|---|---|---|
| `yes-no` | yes or no | `choice`, or `reply` (read as yes or no) |
| `one-of` | one of up to eight labelled options | `choice`, or `reply` with the option's number |
| `text` | free text | `reply` |
| `number`, `date` | a value of that type, within bounds | `reply`. Parsed and checked by the seat's holder; asked again once if it does not parse. |
| `acknowledge` | "seen" | `choice` |
An ask whose answer **performs an action** (approve a retirement, delete data) is the same ask with
an **authorising** flag. It adds requirements of trust, which [08](08-asks-that-authorise.md)
defines. Everything else about it is as below.
### What an ask carries
- an id;
- the asker (its bus principal and its machine);
- the kind, with its options or bounds;
- the words;
- a priority (urgent or normal);
- optionally a timeout and a default;
- optionally a conversation handle (where the asker is talking with the operator);
- optionally a group, for batching.
The words pass the content rule like every message.
### Its life
**open → answered | defaulted | expired | cancelled**
- **Answered:** the first answer wins. Copies of the ask shown on other channels are edited to say
where it was answered.
- **Defaulted:** the timeout passed and the ask declared a default. The asker receives the default,
marked as a default, never as the operator's answer.
- **Expired:** the timeout passed with no default. The asker is told.
- **An authorising ask never defaults to performing.** It expires. ADR 0230's rule, "a timer is the
mesh acting alone again, only later", applies to every ask that authorises.
- **Cancelled:** the asker no longer needs it (`ask cancel`), or its owner sees it is moot (the
condition behind it cleared). Its copies are edited to "no longer needed".
### How the asker gets the answer
- The answer is emitted as **`ask-answered`**, with the ask's id and, where there is one, the
conversation handle.
- An asker may **wait**: `ask` with a wait of up to a few minutes returns the answer if it comes in
time, and otherwise returns the id.
- An agent working through a long task can **poll** `asks <id>`.
### Batching
- **Asks to the same channel within the burst window go out together.** A heading says how many are
open ("3 questions waiting"), and each ask follows as its own message, so each can be answered and
edited alone.
- **An asker may hold at most three open asks.** A fourth is refused to it, in words, so an agent in
a loop cannot flood the operator.
- **Asks count against the router's hourly cap** like messages. Answers do not.
### History
**`asks`** lists open asks, and closed ones for 30 days: the asker, the kind, the outcome, the channel,
and the answer. A free-text answer is the operator's own words. It stays in the router's state and is
never forwarded to a channel other than the one it came from.
## Who holds what
### The output seat's holder becomes the conversation's router
It holds `operator-channel` and gains asks:
- `ask` (create),
- `ask cancel`,
- `asks`,
- `answer` (called by intake holders, below),
- events `ask-opened`, `ask-answered`, `ask-closed`.
It **owns** every ask that authorises nothing.
An authorising ask is **owned by the controller** ([08](08-asks-that-authorise.md)). The router carries
it like any other, but its answer goes to the controller, which alone can perform.
### Where a channel sits: [03](03-open-questions.md) Q1, asked again
Q1 settled that **one seat speaks for the mesh** and that sources never learn channels. It assumed
each channel attaches by contribution. With many channels to come, and channels that answer, that is
the question to settle.
The mesh's precedents:
- A **seat** has one holder at its scope (ADR 0121, ADR 0126).
- A **node seat** has one holder per machine, and a verb's subject carries the machine (design 33 §4).
- A **bench** is a seat with several holders. The only one is `mesh-dns-resolver`: the same module,
one per machine. "Making another one is a decision, recorded" (ADR 0223).
- A **work queue** is shared by a seat's holders (ADR 0190).
- A **contribution** is content another module hands to a seat's holder (ADR 0210, ADR 0212).
The options:
- **a. Each channel contributes itself to the output seat** (Q1 a, to-be 45 §5).
- A contribution is content a holder places.
- A channel is running code: it holds a secret, keeps a connection, reads answers, fails on its own.
- Making it fit puts every channel's client back in the router. **Rejected.**
- **b. One seat per channel kind.** The router learns every seat; a new channel is a change to the
router. **Rejected.**
- **c. Channel modules found by a manifest field and called by module address.** Callers use seats,
never modules (ADR 0126). **Rejected.**
- **d. One monolithic notifier,** every channel built into the router. Shared secrets and failures, a
release per channel. **Rejected.**
- **e. A channel module per service, with its own approval or question path** (a "Telegram module"
that decides things). It locks the conversation to one service, and the next channel repeats it.
**Rejected.**
- **f. Kinded benches.** Two mesh seats, **`channel`** (out) and **`intake`** (in). Their holders are
different modules, each claiming a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …).
- One holder per kind; two claiming one kind is refused at registration.
- A verb's subject carries the kind, as a node seat's carries the machine:
`mesh.seat.channel.tool.send.<kind>`.
- A new channel is a new module claiming a new kind, with no change to the router.
- **Chosen.**
Option f needs a second bench, of a new sort (different modules, keyed by kind), which ADR 0223 says
must be decided. It also needs a claim that carries a kind and capabilities.
## The channel seat (out)
**`channel`**, mesh scope, a kinded bench.
### Served
- **`send`:** words, a priority, whether silent, an optional **ask block** (the kind, the options each
with an opaque token, the ask id) and an optional thread (the conversation handle, or the message
this replies to). Answers the channel's id for what it sent, or a refusal in words.
- **`edit`:** replace a sent message by its id, where `edit` is declared.
- **`standing`:** ready, not configured (naming what is missing, never a value), or failing (since
when, why); the last delivery; the declared capabilities.
### Emitted
- **`delivered`** and **`failed`**, the latter marked **permanent** or **transient** (defect D6 in
[04](04-telegram-as-the-first-holder.md)).
### Honoured
- The declared maximum length, by cutting and saying so (D1).
- Silence where declared (D4).
- An identical edit is a success (D6).
- No own secret in any error or event.
### The content rule
The content rule is the router's, applied before anything reaches a holder that does not declare
`private`.
## The intake seat (in)
**`intake`**, mesh scope, a kinded bench. A service that is read and written by one program (a
Telegram bot, [04](04-telegram-as-the-first-holder.md)) is held by one module claiming both seats under
one kind.
A holder turns what arrives into **one envelope**:
| Field | What it is |
|---|---|
| `id` | Unique, for deduplication. |
| `kind` | The holder's kind. |
| `what` | `message` (written first), `choice` (a button or a reaction), `reply` (written in an ask's thread), `mail`, `call` (a webhook), `seen` (activity, for the work context). |
| `sender` | The identity on that service, and whether the service authenticated it. |
| `operator` | Whether that identity is on the **controller's** list of the operator's identities. Filled from that list, never from the holder's own. |
| `conversation` | An opaque handle. Sending on it reaches the same chat, room or mail thread. |
| `in-reply-to` | The ask or message it answers, if any. |
| `payload` | The text, or the option's token. **No secrets:** a code for an authorising ask never travels in an envelope ([08](08-asks-that-authorise.md)). |
| `at` | When. |
**Answers go to the ask's owner by request and reply:**
- the router's `answer` for an ordinary ask;
- the controller's for an authorising one.
**Everything else is an event on the seat,** which consumers take by `what`:
- the router takes `seen` for the work context;
- an agent bridge takes `message` from the operator;
- a future mail rule takes `mail`.
**One gap to close:** the shared library cannot yet publish on a seat's event subjects (design 32 §1).
## The capability vocabulary
**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim, and
the catalogue refuses a word outside the vocabulary. **Each capability has a contract test** the
holder's build runs and a **drill** its `standing` can run. One that fails its drill is reported and
withdrawn from routing until it passes.
The words are in three groups. The first two serve every conversation. The third exists only for
asks that authorise, and is defined in [08](08-asks-that-authorise.md).
### Delivering
| Capability | Promise | Test / drill |
|---|---|---|
| `deliver` | It arrives, or `failed` says why. | Against a test double; a live test message. |
| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind; the operator acknowledges a drill. |
| `loud` | It can break through the phone's quiet hours. | The service's override is set for urgent. |
| `silent` | It can arrive without sound. | The service's silent flag is set. |
| `edit` | A sent message can be replaced in place. | Edit and read back, against a double. |
| `max-length:N` | Up to N characters arrive whole; longer is cut, and the cut is said. | N+1 characters give a cut message, not a failure. |
| `reaches-when-mesh-down` | Delivering needs neither the bus nor the control node. | Checked by the holder's placement and its send path. |
| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind; reviewed. |
### Conversing
| Capability | Promise | Test / drill |
|---|---|---|
| `choice` | The operator can pick one offered option in one act, and the pick comes back. | A simulated tap gives a `choice` envelope with the option's token. |
| `reply` | The operator can answer in free text, and it comes back. | A simulated reply gives a `reply` envelope. |
| `threads` | An answer is tied to the message it answers. | A reply to message A carries A in `in-reply-to`. |
| `operator-first` | The operator can write to the mesh unprompted. | A simulated message gives a `message` envelope. |
### Trusting
`verified-sender`, `exact-render`, `code-factor` and `key-factor` are defined in
[08](08-asks-that-authorise.md). A channel without them still converses fully. It just cannot carry
an answer that performs an action.
## Agents in the conversation
An agent is a participant. It asks, and it receives.
- **An agent whose operator is at its own terminal** asks there, in the terminal. The seat is not
needed.
- **An agent working unattended** (in the background, on a schedule, or with the operator stepped
away) asks **through the seat**. The router puts the ask where the operator is now
([07](07-the-work-context-and-the-desk.md)): the desk if they are at it, Telegram if they are away
or talking through Telegram. The agent waits for, or polls, the answer.
- **An agent the operator talks to through Telegram** receives the operator's messages as intake
envelopes. It answers on the conversation handle. Its asks carry that handle, so they appear in the
same chat.
- **An agent's words reach the operator only as an asker's words,** and the operator's answer reaches
the agent only as the operator's. An agent relaying "the operator said yes" is not an answer to
anything. That is why an answer comes from a channel holder, never from the asker
([08](08-asks-that-authorise.md)).
## The watcher, in this shape
The watcher's watcher stays **outside** the seats, deliberately:
- it must speak when the bus and the control node are what failed;
- the seats live on the bus.
It is a minimal `deliver` + `reaches-when-mesh-down` sender with its own bot. It never reads, and never
asks. Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)).
## From today to this shape
1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md). No change of
shape.
2. **The operator configures Telegram** ([09](09-a-proposed-decision.md)). The mesh starts telling.
3. **The vocabulary and the kinded bench in the catalogue,** and seat events in the shared library.
4. **Split Telegram out** into its own module holding `channel` and `intake` under `telegram`. The
router keeps the desktop adapter as `channel/desktop` and `intake/desktop`.
5. **Asks in the router,** with buttons and replies on Telegram and actions on the desktop
([07](07-the-work-context-and-the-desk.md)). Agents can ask from here on.
6. **The work context** as the router's ordering ([07](07-the-work-context-and-the-desk.md)).
7. **Authorising asks in the controller** ([08](08-asks-that-authorise.md)).
8. **Operator-first messages,** and an agent bridge consuming them.
9. **Further holders as wanted:**
- Pushover for waking;
- Matrix once its push is measured;
- mail out for the digest, mail in as an intake.
The watcher changes only at step 1.
## What this revisits in [03](03-open-questions.md)
- **Q1:** channels attach as holders of kinded benches (f), not as contributions.
- **Q3:** the life of a message gains the life of an ask. `edit` and `silent` are declared, and decide
how a clearing is said (D2, D3).
- **Q4:** presence becomes the work context ([07](07-the-work-context-and-the-desk.md)).
- **Q7:** answering back is the conversation itself. Its authorising layer is
[08](08-asks-that-authorise.md).
- **Q8:** the content rule stays, applied by the router. Whether a `private` holder may be exempted
is left to graduation.
@@ -0,0 +1,117 @@
# 07 — The work context, and the desk
The operator, 2026-10-06:
- "If dunst can also show buttons, we could prefer to use desktop notifications instead of Telegram
for approval actions when working in a session."
- "The work context is an important factor when deciding the correct output channel."
[06](06-a-conversation-with-the-operator.md) decides **which channels may carry** a message or an ask:
those whose declared capabilities satisfy it. This document decides **which of those comes first**,
from where the operator is working. It also makes the desk a full participant in the conversation.
## The rule
> **A message or ask goes to the most direct channel in the operator's current context, among those
> whose capabilities already satisfy it. Unanswered in time, it escalates along a fixed chain.
> Context orders the candidates; it never adds one. Context never lowers the bar.**
The last sentence matters most for asks that authorise ([08](08-asks-that-authorise.md)). Being at the
desk never makes a click count as more than it proves.
## The signals
| Signal | Source | Read as |
|---|---|---|
| A graphical session unlocked, with input in the last 5 minutes, on machine M | The `node-lock-screen` seat's holder on M (the screen-lock module), whose tools already read the idle time and the lock state. Underneath: logind's `LockedHint` and `IdleSinceHint`. Proposed: an event on each change, not a poll. | **at the desk on M** |
| That session locked, or idle longer | the same | **not at the desk** |
| An agent asking from machine M | The ask's asker names its machine. An agent module's own "session active" event, when one exists. | **working with an agent on M**. It strengthens "at the desk on M"; alone it proves nothing. |
| A verified intake `message`, `reply` or `choice` in the last 15 minutes | The intake seat ([06](06-a-conversation-with-the-operator.md)) | **in a conversation** on that kind |
| An ask carrying a conversation handle | The ask | **that conversation**, whatever else is true |
| The hour, against quiet hours | The router's setting | **night**: only urgent wakes |
## The contexts, and where things go
"The away channel" is the operator's setting (Telegram, to begin with). "The loud holder" is an
optional second away holder for waking (Pushover, [05](05-the-other-holders-on-the-same-axes.md)).
| Context | An ask goes to | Urgent message | Warning | Unanswered or unacknowledged → |
|---|---|---|---|---|
| **In a conversation through Telegram** (the ask carries its handle, or Telegram activity is newer than any desk input) | that chat, in the thread | that chat | that chat, silent | after 10 min (urgent) or 1 h: also the desk, if active |
| **At the desk on M** (with or without an agent there) | the desk on M, if it can carry the ask; otherwise the away channel, and the desk says where it went | the desk on M, and the away channel silently | the desk on M | after 5 min (urgent) or 30 min: the away channel, with sound |
| **Away** (no unlocked active session, no recent conversation) | the away channel | the away channel | the away channel, silent | after 15 min (urgent): the loud holder, if configured |
| **Night, away** | non-urgent asks wait for the morning; urgent as away | the away channel and the loud holder | the morning digest | as away |
| **The desk locks while an ask is shown there** | moves at once to the away channel | — | — | — |
- **Every copy of an ask stays valid until one answer wins.** The others are edited to say where it
was answered.
- **The context's channel cannot carry the ask** (a free-text ask at a desk without a prompt, or an
authorising ask the desk cannot prove): the next in the chain carries it, and the context's channel
says where it went.
- **Nothing can carry it:** the router says so, as a condition of its own.
### Presence stays in the mesh
- **Presence facts are events on the bus,** consumed by the router.
- **They are kept as current state only:** a key-value entry per machine and per intake kind,
overwritten, never a history.
- **They never appear in a message's words,** so they never reach a channel that is not `private`.
- **No module keeps them as a timeline of the operator's day.** A consumer that wants one is a
decision of its own.
## The desk as a participant
Read from the catalogue's main branch and the tools' current documentation, 2026-10-06.
### What exists
- **The `node-notifier` seat** is held on each graphical machine by the dunst module. Its `send` runs
`notify-send --print-id` with an urgency, an application name and an optional replace id. It
carries **no actions** today.
- **libnotify's `notify-send`** (0.8 and later) takes `--action=NAME=Label`, repeatable, and `--wait`.
It prints the chosen action's name when one is chosen, and nothing when the notification is closed.
Underneath, the notification server emits `ActionInvoked` with the notification's id and the
action's key.
- **dunst shows actions:**
- `do_action`, which the module binds to the **middle** click, invokes the default or only action;
- otherwise it opens the **context menu**, which in this mesh is the `node-launcher` seat's
dmenu-compatible menu;
- `dunstctl action` and `dunstctl context` do the same from a command line.
- **The `node-launcher` seat's `menu` verb** shows a list in the operator's session and answers the
chosen line. A dmenu-compatible menu also accepts typed text that is not a listed line, which makes
it a free-text prompt.
- **The graphical session is X11.**
### What it takes
- **`send` gains actions:** a list of (token, label).
- **It still answers at once.** A notification may be answered minutes later.
- **The holder listens for `ActionInvoked`** and emits the chosen token as an event on its node seat.
- **The router's desktop adapter,** which holds `channel/desktop` and `intake/desktop`, turns that
into a `choice` envelope.
- **For `text`, `number` and `date` asks,** the notification's single action opens the launcher's
prompt, and what is typed comes back as a `reply`.
### What the desk can declare
| Group | Capabilities |
|---|---|
| Delivering | `deliver`, `silent` (low urgency), `loud` (critical urgency stays until dismissed), `edit` (replace id), `private` |
| Conversing | `choice` (actions), `reply` (through the launcher's prompt), `threads` (the ask's id is carried) |
| Trusting | **not** `verified-sender`. `exact-render` yes. `code-factor` (a prompt) yes. `key-factor` yes where a security key is plugged in. See [08](08-asks-that-authorise.md). |
So the desk carries **every ordinary ask**: yes or no, one of, text, number, date and acknowledge.
It needs no account anywhere. An agent working unattended on the workstation asks a clarifying question,
and the operator, at the desk, answers it in the notification.
It carries an **authorising** ask only with a factor the controller verifies itself, because a click
on an X11 desk proves that someone was there, not that the operator clicked
([08](08-asks-that-authorise.md)).
## Sources
- `notify-send(1)`, `--action` and `--wait`: https://man.archlinux.org/man/notify-send.1.en
- Desktop notifications and actions: https://wiki.archlinux.org/title/Desktop_notifications
- dunst documentation (mouse actions, `do_action`, the context menu): https://dunst-project.org/documentation/
- logind's `LockedHint`, `IdleHint`, `IdleSinceHint`:
https://freedesktop.org/software/systemd/man/org.freedesktop.login1.html
@@ -0,0 +1,247 @@
# 08 — Asks that authorise
Most asks inform their asker and change nothing ([06](06-a-conversation-with-the-operator.md)). Some
answers **perform an action**: approving a retirement, confirming that a binding moves, deleting data.
This document is the layer those asks need on top of the conversation. It is the controller's checks,
the trust a channel must prove, and what a compromise can reach.
The operator, 2026-10-06:
- "Make sure I can approve and reject stuff via the Telegram channel."
- In a terminal session with an agent, having to open Telegram is acceptable.
- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session.
- At the desk, desktop buttons would be preferred ([07](07-the-work-context-and-the-desk.md)).
## Where this stands against what was decided
- To-be 45 §5 says: "No answering back in this form".
- ADR 0227 kept it open on purpose: "routing by presence, quiet hours, **answering back** and the
external dead-man service stay open in 028, whose graduation amends to-be 45."
- So this answers 028's Q7. It does not reverse ADR 0227.
## What there is to authorise
Read from the controller's main branch on 2026-10-06. The condition store marks conditions only a
person resolves (`resolver: operator`): from the start for retirement, clean-up and binding conditions,
and once a healer's budget is spent.
| Action | The verb today | Asked for by | Reversible | Tier |
|---|---|---|---|---|
| Approve the retirement set waiting | `retire approve <node> <provider> --why` | `retire-waiting` (urgent) | yes: asking for a consumer again re-enables it | approve |
| Reject it | `retire reject … --why` | `retire-waiting` | yes | approve |
| Approve a set rejected before | `retire approve …` | `retire-rejected` (warning) | yes | approve |
| Confirm that a binding moves, once its data is moved | `pin <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes. The data, not by the mesh. | approve |
| End a stuck plan | `plans stop` / `close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages skipped or redelivered | approve (graduation to confirm) |
| Try a healer's repair once more | the healer's ordinary path | any escalation (H1–H5), `healers-braked` | as the repair is | approve |
| Silence a condition | `conditions silence <key> --for --why` | any | yes, it ends by itself (at most 7 days) | acknowledge |
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (after 30 days) | **no** | destroy |
| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy |
| Change the operator's identities, the away channel, or a factor's enrolment | (new) | — | yes, but it changes who may authorise | destroy |
The build queue verbs and `replay --register` are not offered as asks: no condition asks for them.
## Trust, as capabilities and proofs
The conversation's vocabulary ([06](06-a-conversation-with-the-operator.md)) gains four words used
only here:
| Capability | Promise | Test / drill |
|---|---|---|
| `verified-sender` | The holder proves the answer came from the operator's own account on that service, by the service's authentication, through a holder **no agent shares**: not on the operator's account, not on a machine where agents run as the operator. | A choice from an identity not on the list is dropped and reported. The holder's placement is checked. |
| `exact-render` | The ask is shown as the controller rendered it, by the holder itself. No asker or agent composes the words the operator authorises. | Rendered text equals the controller's, byte for byte, against a double. |
| `code-factor` | The holder can carry a code the operator types to the controller, by request and reply, and never judges it. | A code is never in an event, and is deleted from the conversation where the service allows. |
| `key-factor` | The holder can run a security key's assertion over the controller's challenge, and hand the controller the result. | The challenge is the controller's, and the signature is checked by the controller. |
From these, three **proofs** that the operator is the one answering:
- **P1, a verified sender:** a Telegram tap, through a holder on a machine no agent runs on.
- **P2, a code:** from the operator's authenticator, verified by the controller.
- **P3, a key touch bound to the ask:** the controller's challenge is a hash of the ask's id and the
state digest. A FIDO2 assertion with user presence (the key waits for a touch) is checked against the
operator's enrolled credential. It proves a physical touch for **this** ask and no other.
## Three tiers
| Tier | Required |
|---|---|
| **acknowledge** | `choice`, `exact-render`. Silencing is open to agents already, and announced; a proof adds nothing. |
| **approve** | `choice`, `exact-render`, and **one** proof (P1, P2 or P3). Single use, bound to the exact state shown, expiring when that state changes or after 24 h. |
| **destroy** | `choice`, `exact-render`, and **two** proofs, at least one of them P2 or P3. Valid 10 minutes after it is shown; at most one destroy answered per 10 minutes. |
| Where the operator answers | Proofs it offers | acknowledge | approve | destroy |
|---|---|---|---|---|
| Telegram | P1 (tap), P2 (code as a reply) | tap | tap | tap and code |
| the desk, with a security key | P3 (touch), P2 (code in a prompt) | click | click and touch | click, touch and code |
| the desk, without a key | P2 (code in a prompt) | click | click and code | not possible: carried by the away channel |
| an agent's terminal | none | none | none | none: an agent asks, it never answers |
| the console (a shell) | P2 (code) | — | break-glass: a code | none |
### The rule
1. **Every authorising verb declares its tier** in the controller's verb table.
2. **An authorising ask is offered only on a channel whose capabilities satisfy its tier.** An answer
arriving from any other channel is refused, and the refusal is said there.
3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A setting
that would make a tier possible only at a desk is refused, unless the operator chose that for the
tier explicitly. While working through Telegram, everything can be completed in Telegram.
4. **The work context chooses among the channels that qualify; it never makes one qualify**
([07](07-the-work-context-and-the-desk.md)).
5. **No agent authorises.** An agent asks. It holds no verb that performs an authorising action. The
record names the agent that asked.
## Why the desk needs a factor
- **X11 does not isolate the clients of one display.** Any of them can inject input (the XTEST
extension, as `xdotool` does) and read keystrokes.
- **`dunstctl action` invokes a notification's action** for any program of the account.
- **The desktop holder runs as the operator's account,** whose files, the holder's bus credential
included, every agent on that account can read.
So a click at the desk, its `ActionInvoked` and the desktop holder's envelope can all be produced by
an agent. An unlocked session with recent input proves a person was there, not that the person
clicked. The desk declares no `verified-sender`. A factor the **controller** verifies gets around
that.
| Factor at the desk | Can an agent on the account fake it? | Judgement |
|---|---|---|
| **A security key's touch, bound to the ask** (P3) | No: the touch is physical, and the signature covers this ask's id and state. | **Preferred.** One click, one touch, no phone. Needs a key and an enrolment. |
| **A code typed into the launcher's prompt** (P2) | It cannot know the code. On X11 it can read keystrokes and race to use the code first, and each step's code is accepted once. | **Acceptable** without a key. Costs picking up the phone. The race is a residual risk until the session leaves X11. |
| **The screen's unlock or a fingerprint** | Yes: only the local holder sees the result. | **Rejected.** |
The desk would earn `verified-sender` only if agents ran under an account of their own, without the
operator's display, session bus or holders' credentials, on a compositor that isolates clients. That
is a question for the agent modules' placement. It is noted, not proposed.
## The controller holds authorising asks
Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approving and listing alike).
A hand-act records `by` from the calling bus principal, which for a channel would be the module, not
the person. Both call for the controller to hold these asks itself.
- **The verb table gains a field.** The controller's verb definition (a name, a description, input and
output schemas) gains **`authorises`**: the tier, and the arguments that make up the exact state a
person must see (for `retire approve`, the set of consumers).
### Three verbs
- **`authorise request`:** anyone may call it, an agent or the router on a condition's behalf. It
carries the action (verb and exact arguments), why, and optionally a conversation handle.
- The controller renders the ask: what is asked, the exact state, who asks, what each option does.
- It stores it with an opaque id (10 random base32 characters), its expiry and a digest of the
state shown, and emits `ask-opened` with the controller as owner.
- The router carries it like any ask. **Nothing is performed.**
- **`authorise answer`:** only intake holders are granted it. It carries the id, the option, the
sender's identity, and the code or key assertion where the tier needs them. The controller checks,
and refuses at the first failure:
1. the ask is open and not expired;
2. the caller is the holder of an intake kind, by the controller's own seat records, never by the
request's claim;
3. that kind's declared capabilities, from the controller's records, satisfy the tier, and the
proofs present are enough;
4. a P1 answer: the sender is on the controller's list of the operator's identities for that kind;
5. a code: valid for the current or previous 30-second step, and unused;
6. a key assertion: it verifies against the enrolled credential, over this ask's challenge, with the
user-presence bit set;
7. the state **now** has the digest it had when shown. Otherwise the ask is void and a new one is
requested.
Then it performs the action as itself, records the hand-act, closes the ask with a compare-and-set
(so a second answer on another channel loses), and emits `ask-answered`.
- **`authorisations`:** open and recent authorising asks.
### The authorising verbs refuse to be called directly
`retire approve|reject`, `cleanup delete`, and `pin` while a `binding-kept` names it refuse a caller
unless the call comes through `authorise answer`, or carries a valid code as **break-glass** at the
console. Break-glass is recorded as such, and announced on every channel.
- `retire approve` must accept the set it approves (`expect`). Today it re-reads the set when it
runs, so "approve what you were shown" (ADR 0230) does not hold end to end.
- `conditions silence` stays callable. A silence an agent sets is said on the away channel, with its
why.
### The record
The hand-act gains:
- **`via`:** the kind and the holder;
- **`requested-by`:** the agent principal or the condition key;
- **`ask`:** the id;
- **`proofs`:** which of P1, P2 and P3 were present.
`by` reads "the operator, as <kind> identity <id>". Every copy of the ask is edited to the outcome
("approved by the operator on telegram at 14:02 UTC"), and its buttons are removed.
## Telegram, carrying them
- **Buttons.** `callback_data` is at most 64 bytes, so a button carries only `a1:<id>:<option>`.
Everything else is in the controller.
- **A tap** arrives as a `callback_query` with the tapping user's id and the chat. The holder:
1. drops it, and reports, unless both are the operator's;
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
3. calls `authorise answer`;
4. edits the message to the outcome or the refusal.
- **A destroy ask** answers the tap with a `ForceReply` prompt for the code. The holder reads the
operator's reply, deletes it from the chat (bots may delete incoming messages in private chats), and
hands it to the controller by request and reply. It is never put in an event.
- **Long polling, not a webhook.**
- `getUpdates` needs no route into the mesh.
- A stolen token can steal updates, and a second reader shows as HTTP 409, but it cannot inject an
update.
- A webhook needs a public route, and a stolen token can redirect it.
- The offset is kept in the holder's state, and ids are single use anyway.
- **Placement.** The Telegram holder runs where no agent runs as the operator. Its own declaration of
`verified-sender` depends on it.
## Agents
- **In a terminal:**
1. The agent calls `authorise request`.
2. The router carries the ask to where the operator is ([07](07-the-work-context-and-the-desk.md)):
the desk, with a factor, or Telegram.
3. The agent sees `ask-answered`.
Nothing the agent says counts.
- **Through Telegram:** the request carries the conversation handle, so the ask appears in the same
chat, rendered by the holder, and the operator taps in place, destroy included.
## If something is compromised
| What is lost | What the attacker can do | What limits it |
|---|---|---|
| The bot token | Read what the bot is sent from then on. Steal taps (visible as 409). Send the operator fake messages. | It cannot call `authorise answer`. Revoke with BotFather's `/token`. |
| The operator's Telegram account, on a new device | Approve or acknowledge. | Telegram's two-step password. Every authorisation is announced on the other channels. Approve is reversible. Destroy needs a code. |
| The phone, unlocked | Everything, including destroy, if the authenticator is open on it. | An authenticator behind biometrics. One destroy per 10 minutes, announced. Backups (research 030). A setting turning destroy off for the away channel. |
| An agent on the operator's account | Click at the desk, read the desk's keystrokes, read the desktop holder's credential. | No tier accepts the desk without a code or a key touch the controller verifies. A code read off X11 is good for one step, and the race is said above. |
| A channel module, or its bus account | Forge P1 for approve or acknowledge. | It cannot forge P2 or P3. Destroy needs one of them. |
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs P2 or P3, which Telegram never sees. |
**The factors' secrets are the controller's own.**
- The TOTP seed is made by the mesh. It is enrolled by showing its URI once, only to a terminal, and
never through a channel or an event.
- A security key is enrolled by registering its credential's public key.
- Re-enrolling either is a destroy ask.
## The other holders, for authorising
- **Matrix** can declare everything Telegram does: reactions, replies, a sender authenticated by the
mesh's own homeserver, codes. It is the self-hosted carrier once its push is measured.
- **ntfy:** an `http` action makes the phone call the mesh, with a credential inside the notification.
Nobody knows who tapped, and there is no reply. No tier.
- **Pushover:** acknowledgement, read back by polling a receipt. At most `acknowledge`.
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
## Sources
As in [04](04-telegram-as-the-first-holder.md) and [07](07-the-work-context-and-the-desk.md), and:
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage`:
https://core.telegram.org/bots/api
- `answerCallbackQuery` is required even with no text: https://gramio.dev/telegram/methods/answercallbackquery
- X11 and its clients (input injection, keystroke reading):
https://hackindex.io/services/x11/exploitation/x11-session-hijacking and
https://www.semicomplete.com/projects/xdotool/
- `fido2-assert` (user presence, verifying an assertion):
https://developers.yubico.com/libfido2/Manuals/fido2-assert.html
- ntfy `http` actions: https://docs.ntfy.sh/publish/
- Pushover receipts: https://pushover.net/api

Some files were not shown because too many files have changed in this diff Show More