Compare commits

..
Author SHA1 Message Date
jochen e3a5862c5a The operator's agent is a module: three records and to-be 36 for the claude-code successor
The predecessor's agent module was retired and its six files stayed on both workstations telling
every session to use tools that no longer exist. Before a successor module is written, the design
needs the decisions it rests on and nothing in the record stated them:

- ADR 0169 (reconstructed) records what the controller shipped on 2026-09-27 without a record: the
  operator account is a node fact stated by the operator, the home is derived unless stated, a
  resource may be placed under it owned by the account, and a node with no account refuses one.
- ADR 0170 generalises to-be 29 §3's found-vs-owned boundary to every directory under a home: the
  module owns the directory and the files it places, writes into the tool's own files for its few
  keys, never declares a credential's content, and holds everything else as found — a predecessor's
  leftovers included, which the operator removes once.
- ADR 0171 draws the licence line the operator asked to have drawn rather than assumed: the mesh
  binds and delivers (to-be 14 and 15 stand), the module alone writes the credential file, refresh
  stays central (ADR 0050), a switch is the binding changed through a controller seat verb asked
  for via the console, and the token-carrying shell helper is retired. The controller learns
  nothing about the agent; that is what "no part" means.

To-be 36 is the module's design: the ownership map per path, the fate of the six predecessor
files, what the three instruction documents say, the licence tools and skill, the console as a
node-scoped provision, the package gap stated honestly, and the order of the build. To-be 14, 29
and 34 carry dated notes; the glossary gains "operator account".
2026-10-02 16:37:48 +02:00
548 changed files with 1257 additions and 50393 deletions
+5 -26
View File
@@ -21,28 +21,9 @@ 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
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
1. Load `mesh_call` and `mesh_describe` (deferred tools of the `mesh` MCP server) and open the issue:
`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:
1. Next free number. Create `04-ISSUES/NNN-short-name/00-report.md` with:
```yaml
---
@@ -54,15 +35,13 @@ Decide the shape first:
---
```
Its status stays in this frontmatter until slice 3 of to-be 53 imports the folders (ADR 0275);
`issue-tracker.move` refuses it until then. Notes and links may still go to the 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
2. Write the symptom **as observed**, in plain terms, with the evidence that it happened —
what was run, what came back, when.
3. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
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 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 renumber a folder or reuse a number: a number never moves.
+3 -9
View File
@@ -23,12 +23,8 @@ generated on demand and never written back to disk.
1. Read the frontmatter block from every file above — a grep across each tree is enough, no
need to load bodies.
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** is one
of these views, and it is the only place the list of records is shown
([ADR 0248](../../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)):
run `python3 00-META/checks/index.py --print` for the records grouped by topic in the reading
order `02-DECISIONS/README.md` writes, each with its status when not `accepted`. Asked for it by
number instead, render records ordered by number, with title, date and status, and reconstructed
ones marked. Off this checkout, the records module's `records_decisions` answers the same list.
of these views: records ordered by number, with title, date and status, and reconstructed
ones marked.
3. Flag anything inconsistent at the end, as flags — do not silently correct the render:
- a `graduated` or `abandoned` effort with an empty `became:`
- an `implemented` design with an empty `code:`
@@ -41,7 +37,5 @@ generated on demand and never written back to disk.
- **Do not write a status file.** Central status files are explicitly rejected. The view is
always generated, always ephemeral. This includes the ADR index — the hand-written one had
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
is why that went too. `index.py` fails if the list is stored there again.
already drifted after a single addition, which is why it was removed.
- Do not infer status from prose. Trust only the frontmatter; if it is wrong, flag it.
-1
View File
@@ -1 +0,0 @@
__pycache__/
+5 -53
View File
@@ -2,11 +2,8 @@
```
python3 00-META/checks/records.py structure: links, citations, supersession, topics
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/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 --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
python3 00-META/checks/index.py the reading order in 02-DECISIONS/README.md is current
python3 00-META/checks/index.py --write regenerate it
```
Non-zero exit on any problem, so it can be a gate rather than a report.
@@ -27,8 +24,8 @@ indistinguishable from one that cannot.
| `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 |
| `supersession` | if A says it was superseded by B, B says it supersedes A | ADR 0012 never declared that it superseded 0011 |
| `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 |
| `topics` | every record names a topic the index knows | — |
| *(index.py)* | the written reading order matches what the records say | — |
| `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 |
## What is deliberately not checked
@@ -59,49 +56,4 @@ a to-be design names a decision, an in-progress/implemented design names its own
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
research overview says what it became, and no two issue records share a number (issue 155 — the
number is how a record is cited, and `main` lags every open pull request, so two people reading it
allocate the same one). And a core issue — one opened from 2026-10-07 whose `located-in` names a core
repository — resolves only with `replay:` (an id in mesh-lab's replays register) or `replay-none:` saying
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));
it failed on a resolved core issue carrying neither before it passed. And an accepted record that puts work
off — "until renamed", "left for later", "not decided here", "postponed", "future work", "deferred", "a later
change" — names the issue that tracks it, as tracking it ("tracked by issue N", or "issue N tracks"), in that
paragraph or list item, or in a dated progressive insight that speaks about it: one in the same section, or one
that quotes it. An issue cited for another reason tracks nothing, and one insight does not excuse every deferral
in a record. A rejected option is not work put off and is not held, judged by its `##` section, so an option under a
`###` subheading of "Considered Options" is skipped too
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md):
ADR 0244 deferred the code renames with no issue, and two days later the operator believed them done). Held to
it: a record dated from 2026-10-09, and an older one corrected under the rule. Measured from ADR 0244's date
it found deferrals without an issue in ADR 0241, 0244 and 0270; held from 2026-10-09 it failed on 0270 and 0273
(whose "not decided here" sits beside a citation of issue 340 that tracks none of it), and on 0244's four
deferral places once its insight brought it under the rule — on three of them while the insight, in §6, named
the issue without quoting them — before each named its issue (355, 357, 354). `python3 00-META/checks/cycle.py`
## words.py
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
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/`,
`03-DESIGN/`, `AGENTS.md`, `README.md`, and research and issues dated from 2026-10-07. Decision records are
never checked. It 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
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.
It also fails on a **daemon's line** ([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),
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 research and 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 —
"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
added it. A retired word's plural (`s` or `es` on its last word) is matched as the word: until 2026-10-07
it was not, so "alerts", "rollouts" and "the hosts" (the node-engines) passed while their singulars
failed; the plural failed on 15 uses in 10 documents before it passed. A verb that reads like a plural
("a node hosts") is a finding too, and is reworded. Homonyms (*plan*, *gate*, *tier*, *ask*, *store*, *record*, *check*) are not checked by it: a
word list cannot tell one sense from another, so they are reviewed.
allocate the same one). `python3 00-META/checks/cycle.py`
Binary file not shown.
-126
View File
@@ -16,11 +16,6 @@ What is enforced:
once `resolved`, `fixed-by:` says what fixed it (prose counts --
"nothing, the capability existed" is an answer). And no two records share a
number -- the number is how a record is cited.
replays a core issue -- one opened from 2026-10-07 whose `located-in:` names a core repository --
resolves only with `replay:` (the replay's id in mesh-lab's replays register) or
`replay-none:` (why no replay is possible): ADR 0237, to-be 45 §9. A replay is what
proves a fix fails before and passes after; without one, a fixed class comes back
through a different door (research 031: four such chains in one week).
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
@@ -46,85 +41,6 @@ DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abando
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
# The core, as to-be 45 names it, by where its code lives: the controller, the node-engine, the node
# tools and the console, the SDK's loops, and the catalogue's bus, forge (the announcer of merges) and
# build agent. A `located-in:` entry naming any of these makes an issue a core issue.
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")
# A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code
# renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date;
# two days later the operator believed them done, and nothing in the record said otherwise. A deferral
# nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated
# from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then
# on): an edit made under the rule is held to it. The phrases are the ones records use to put work off;
# "for later" alone is not one, because records say "never queued for later" and "too narrow for later".
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
# or quoting it (ISSUE_REF, covers).
DEFERRALS_FROM = "2026-10-09"
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
r"\ba later rename|not renamed here|identifier to rename|"
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
r"\bdeferred\b|later work|not built here|\ba later change")
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
def paragraphs(path):
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
out, line, section, chapter = [], first, "", ""
for chunk in body.split("\n\n"):
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
if stripped:
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
if heading:
section = heading.group(1).strip()
if len(stripped) - len(stripped.lstrip("#")) <= 2:
chapter = section
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
# issue named in one item tracks nothing in the next.
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
for item in items:
out.append((at, section, chapter, item))
at += item.count("\n") + 1
line += chunk.count("\n") + 2
return out
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
def flat(text):
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
def covers(insight_section, insight_text, section, text):
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
if insight_section == section:
return True
body = flat(text)
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
def issue_refs(text, issues):
return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and n in issues}
def rel(path):
return os.path.relpath(path, ROOT)
@@ -243,16 +159,6 @@ def main():
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 "")
if (status == "resolved" and core and opened >= REPLAYS_FROM and not replay
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"))):
@@ -302,38 +208,6 @@ def main():
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")
# ---- deferrals -------------------------------------------------------------------
# A paragraph or list item that puts work off names the issue that tracks it, or a dated
# progressive insight that speaks about it (its section, or a quotation of it) does. An issue that has
# since resolved still counts: the deferred work landed, which is what the pointer was for.
for name, (path, status) in records.items():
front = frontmatter(path) or {}
decided = str(front.get("date") or "")
if status != "accepted":
continue
paras = paragraphs(path)
insights = [(d, sec, text) for line, sec, _, text in paras for d in INSIGHT.findall(text)]
held = decided >= DEFERRALS_FROM or any(d >= DEFERRALS_FROM for d, _, _ in insights)
if not held:
continue
# A dated insight naming an issue covers the deferrals it speaks about -- those in its own section,
# or ones it quotes -- and no other: one insight must not excuse every deferral in the record.
tracking = [(sec, text) for d, sec, text in insights if issue_refs(text, seen)]
for line, section, chapter, text in paras:
m = DEFERRAL.search(text)
# A rejected option is a decision not to do it, not work put off -- judged by its chapter, so an
# option under a `###` subheading of "## Considered Options" is skipped as well.
if re.match(r"(?i)(options? rejected|considered options|rejected)", chapter):
continue
if not m or issue_refs(text, seen):
continue
if any(covers(isec, itext, section, text) for isec, itext in tracking):
continue
bad(path, "line %d defers work (%r) and names no issue that tracks it -- a deferral nothing "
"tracks reads as done (issue 354). Name the issue beside it, with its owner, or in a "
"dated progressive insight that says what it tracks"
% (line + text.count("\n", 0, m.start()), m.group(0)))
checked = (
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
+47 -54
View File
@@ -1,18 +1,16 @@
#!/usr/bin/env python3
"""The reading order of the decision records: its topics are written, its list is generated.
"""Generate the decision index, and check the written one still matches.
A number identifies a record and never changes, so the folder listing is creation order rather
than reading order. The reading order is the six topics `02-DECISIONS/README.md` writes down, in
order, and each record's own `topic:`. The list of records under each topic is generated from
that frontmatter when somebody reads it, and never stored (ADR 0248, hq issue 297): a stored
list was a line every decision pull request added to one file, so any two open at once conflicted.
than reading order. The index is what carries the path — and it is written rather than only
generated on demand, because a reader on a forge sees the folder and not a command.
python3 00-META/checks/index.py check
python3 00-META/checks/index.py --print print the list, by topic, in reading order
The objection to a written index is that it drifts. That objection is answered by checking it
rather than by refusing to write one, which is `how-we-build` §5: a rule states how it is
checked.
It fails when the README's reading order is missing, when a record's topic is not one of its
topics, and when the README stores a list of records again — the index markers, or a line of
the generated list. The last one is what keeps the conflict from coming back.
python3 00-META/checks/index.py --write regenerate it
python3 00-META/checks/index.py fail if it is stale
"""
import glob
@@ -22,26 +20,20 @@ import re
import sys
README = "02-DECISIONS/README.md"
START = "<!-- index:start -->"
END = "<!-- index:end -->"
# A topic in the README's reading order, e.g.
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
# 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)
# What a stored list looks like: the old markers, or a line of the list this script prints.
STORED = [
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of the generated list"),
# The reading order. Topics a record may belong to, in the order somebody would learn the system.
TOPICS = [
("the mesh", "What the mesh is"),
("the tiers", "Its tiers, from the bottom up"),
("what runs on it", "What runs on them, and how it gets there"),
("building it", "How it is built"),
("checking it", "How it is checked"),
("how we work", "How we work"),
]
def reading_order(text=None):
"""The topics, in order, as (topic, label) pairs, from the README."""
if text is None:
text = io.open(README, encoding='utf-8').read()
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
def field(text, name):
m = re.search(r'^%s:\s*(.+)$' % name, text, re.M)
return m.group(1).strip() if m else None
@@ -62,9 +54,10 @@ def records():
return out
def render(topics, rs):
lines = []
for topic, label in topics:
def render(rs):
known = {t for t, _ in TOPICS}
lines = [START, ""]
for topic, label in TOPICS:
rows = [r for r in rs if r["topic"] == topic]
if not rows:
continue
@@ -74,40 +67,40 @@ def render(topics, rs):
mark = "" if r["status"] == "accepted" else " *(%s)*" % r["status"]
lines.append("- **%s** — [%s](%s)%s" % (r["number"], r["title"], r["file"], mark))
lines.append("")
stray = [r for r in rs if r["topic"] not in known]
if stray:
lines.append("### Unfiled")
lines.append("")
for r in stray:
lines.append("- **%s** — [%s](%s) — `topic:` is %r, which is not one of %s" % (
r["number"], r["title"], r["file"], r["topic"], ", ".join(sorted(known))))
lines.append("")
lines.append(END)
return "\n".join(lines)
def main():
text = io.open(README, encoding='utf-8').read()
topics = reading_order(text)
rs = records()
wanted = render(records())
if "--print" in sys.argv:
print(render(topics, rs))
if START not in text or END not in text:
print("index: %s has no index markers (%s / %s)" % (README, START, END))
return 1
current = text[text.index(START):text.index(END) + len(END)]
if "--write" in sys.argv:
if current == wanted:
print("index: already current")
return 0
io.open(README, 'w', encoding='utf-8').write(text.replace(current, wanted, 1))
print("index: written")
return 0
problems = []
if not topics:
problems.append("%s writes no reading order: no line of the form "
"'1. **Label** — `topic: name`. ...'" % README)
names = [t for t, _ in topics]
if len(set(names)) != len(names):
problems.append("%s names a topic twice in its reading order" % README)
for pattern, what in STORED:
if pattern.search(text):
problems.append("%s stores the list of records (%s). The list is generated when read "
"(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:
print("index: " + p)
if problems:
if current != wanted:
print("index: %s is stale. Regenerate it:\n"
" python3 00-META/checks/index.py --write" % README)
return 1
print("index: %d topics, %d records, no stored list" % (len(topics), len(rs)))
print("index: current")
return 0
+5 -6
View File
@@ -265,15 +265,14 @@ def check_supersession_symmetry(failures, records):
def check_topics(failures, records):
"""Every record names a topic of the reading order.
"""Every record names a topic the index knows.
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.
nobody defined — disappears from the index rather than appearing in the wrong place. That is
the quiet failure, so it is the one checked.
"""
from index import reading_order
known = {topic for topic, _ in reading_order()}
known = {"the mesh", "the tiers", "what runs on it", "building it", "checking it",
"how we work"}
for number, record in sorted(records.items()):
topic = record["front"].get("topic")
if not topic:
-17
View File
@@ -1,17 +0,0 @@
# Words allowed — documents `words.py` does not hold to the glossary's retired words
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
themselves — or `kept` for a graduated research effort, which records what was said and keeps its words
the way a decision record does. A row whose document no longer uses a retired word fails too, so this
list only shrinks.
| 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 |
| `01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md` | kept | the effort that settled the word: it records the operator's ask in the words it was made in |
-384
View File
@@ -1,384 +0,0 @@
#!/usr/bin/env python3
"""One word per thing, checked (ADR 0244).
The glossary (00-META/glossary.md) is the authority on the mesh's words. It names the words it
retired on one fixed kind of line, so a program can read them:
*Not:* ~~control plane~~, ~~overlay~~ (hq)
A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only;
`(tools)` in the descriptions of the mesh's tools only. And it names code that still carries an old
name until that code is renamed:
*Identifier until renamed:* `mesh-host` — the repository, binary and service unit
An identifier is allowed inside a code span and nowhere else.
What is enforced:
retired no retired word of scope `hq` (or none), and no identifier, in running prose of a
checked document. Running prose is what is left once code blocks, code spans, block
quotes, text in quotation marks, struck-through text, link targets, HTML comments and
frontmatter are taken out: a quotation keeps the words it quotes, a link target is a file
name, and an identifier lives in a code span. A word's plural is the word: "alerts" fails
as "alert" does.
unique no head word (a bolded word opening a glossary entry) heads two entries, and no head
word is also struck through on a *Not:* line.
tools list with `--list tools` it prints the words retired in the tools' descriptions, which is the
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.
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
controller (`mesh-controller`) are daemons, and a person never types their names — so a
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; a research
effort initiated, or an issue opened, on or after FROM (on or after LINES_FROM for the lines). Never
02-DECISIONS/: a decision record keeps the words it was written with.
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
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 --list tools
python3 00-META/checks/words.py --list lines
"""
import datetime
import os
import re
import sys
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
GLOSSARY = "00-META/glossary.md"
ALLOWED = "00-META/checks/words-allowed.md"
# The day the rule began (ADR 0244): research and issues from it on are held to it.
FROM = "2026-10-07"
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)
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
CODE_SPAN = re.compile(r"`([^`]+)`")
HEAD = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*", re.M)
def rel(path):
return os.path.relpath(path, ROOT)
def read(path):
with open(os.path.join(ROOT, path), encoding="utf-8") as handle:
return handle.read()
def frontmatter_field(text, name):
if not text.startswith("---\n"):
return None
end = text.find("\n---", 4)
m = re.search(r"^%s:\s*(\S+)" % name, text[4:end], re.M)
return m.group(1) if m else None
def glossary():
"""The retired words with their scope, the identifiers, and the head words."""
text = read(GLOSSARY)
retired = []
for line in NOT_LINE.findall(text):
for word, scope in STRUCK.findall(line):
for one in word.split(" / "):
retired.append((one.strip(), scope or "all"))
identifiers = []
for line in IDENT_LINE.findall(text):
identifiers += [i.strip() for i in CODE_SPAN.findall(line)]
heads = []
for head in HEAD.findall(text):
heads += [h.strip() for h in head.split(" / ")]
return retired, identifiers, heads
def pattern(word):
"""A whole-word, case-insensitive match; a space in the word matches a space, a line break or a hyphen.
The plural is the word too: its last part may end in `s` or `es` ("alerts", "control planes"), since
a retired word does not come back by being counted. Anything else joined on is another word.
"""
parts = [re.escape(p) for p in word.split()]
return re.compile(r"(?i)(?<![\w-])" + r"[\s-]+".join(parts) + r"(?:e?s)?(?![\w-])")
def blank(match):
return re.sub(r"[^\n]", " ", match.group(0))
def prose(text, glossary_file=False):
"""The text with everything that is not running prose blanked, keeping offsets and line numbers."""
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
steps = [
r"(?ms)^\s*```.*?^\s*```", # code blocks
r"(?s)<!--.*?-->", # comments
r"(?m)^\s*>.*$", # block quotes
r"`[^`\n]+`", # code spans
r"\]\([^)\s]*\)", # link targets
r"~~[^~\n]+~~", # struck through: a word named as retired
r"\"[^\"\n]*\"", # "quoted"
r"\u201c[^\u201d]*\u201d", # curly double quotes
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
]
if glossary_file:
steps[:0] = [NOT_LINE.pattern, IDENT_LINE.pattern]
for step in steps:
text = re.sub(step, blank, text)
return text
def research_and_issues(since=FROM):
"""The research efforts and issue reports dated on or after `since`, file by file."""
out = []
for folder, key, first in (("01-RESEARCH", "initiated", "00-overview.md"), ("04-ISSUES", "opened", "00-report.md")):
base = os.path.join(ROOT, folder)
for effort in sorted(os.listdir(base)):
head = os.path.join(base, effort, first)
if not os.path.isfile(head):
continue
date = frontmatter_field(read(rel(head)), key)
if not date or date < since:
continue
for name in sorted(os.listdir(os.path.join(base, effort))):
if name.endswith(".md"):
out.append("%s/%s/%s" % (folder, effort, name))
return out
def checked_documents(since=FROM):
docs = []
for entry in ALWAYS:
path = os.path.join(ROOT, entry)
if os.path.isfile(path):
docs.append(entry)
continue
for base, dirs, files in os.walk(path):
dirs.sort()
for name in sorted(files):
if name.endswith(".md"):
docs.append(rel(os.path.join(base, name)))
return docs + research_and_issues(since)
def allowed():
"""words-allowed.md: one row per document — | `document` | until | why |.
`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 = {}, []
if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
return out, problems
today = datetime.date.today().isoformat()
for line in read(ALLOWED).splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) != 3 or not cells[0].startswith("`"):
continue
doc, until, why = cells[0].strip("`"), cells[1], cells[2]
if not why:
problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc))
if until == "kept":
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
elif until < today:
problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until))
continue
out[doc] = until
return out, problems
def check_retired(retired, identifiers):
problems = []
allowance, problems_allowed = allowed()
problems += problems_allowed
words = [(w, pattern(w), "retired") for w, scope in retired if scope in ("all", "hq")]
words += [(i, pattern(i), "an identifier, allowed only in a code span") for i in identifiers]
used = set()
for doc in checked_documents():
text = prose(read(doc), glossary_file=(doc == GLOSSARY))
for word, rx, why in words:
for m in rx.finditer(text):
if doc in allowance:
used.add(doc)
continue
line = text.count("\n", 0, m.start()) + 1
problems.append("%s:%d: %r is %s (glossary)" % (doc, line, m.group(0).replace("\n", " "), why))
for doc in allowance:
if doc not in used:
problems.append("%s: %s no longer uses a retired word — remove its allowance" % (ALLOWED, doc))
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):
problems = []
seen = {}
for head in heads:
key = head.lower()
if key in seen:
problems.append("%s: %r heads two entries" % (GLOSSARY, head))
seen[key] = True
for word, _ in retired:
if word.lower() in seen:
problems.append("%s: %r is a head word and also retired" % (GLOSSARY, word))
return problems
def tools_list(retired):
return sorted({w for w, scope in retired if scope in ("all", "tools")}, key=str.lower)
def check_copy(retired):
catalogue = os.environ.get("MESH_CATALOG_DIR")
if not catalogue:
print("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
return []
path = os.path.join(catalogue, "retired-words")
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 ["the catalogue's copy of the retired words cannot be read: %s" % err]
want = tools_list(retired)
problems = []
if sorted(copy, key=str.lower) != want:
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want))
path = os.path.join(catalogue, "retired-lines")
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):
retired, identifiers, heads = glossary()
if argv[1:2] == ["--list"]:
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)})
print("\n".join(words))
return 0
if not retired:
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
return 1
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired)
for p in problems:
print(p)
if problems:
print("words: %d problem(s)" % len(problems))
return 1
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; "
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads)))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+1 -1
View File
@@ -11,7 +11,7 @@ Imagine the mesh works as intended. What is different?
An agent says what it wants — from a terminal, a phone, a message — and the mesh takes it
from there. It works out which nodes are involved, does the work, and returns a
result. Nobody opens a terminal, recalls which node holds what, or follows a runbook
result. Nobody opens a console, recalls which node holds what, or follows a runbook
written months ago.
The interface is intent. The mesh handles the rest.
+84 -595
View File
@@ -1,613 +1,102 @@
# Glossary — the words of the mesh, by domain, and the ones it stopped using
# Glossary — the words this repository uses, and the ones it stopped using
One name per thing. This page is the authority; where an older record says something else, that
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
"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](../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).
record is being superseded, not this page. It exists because the terms kept drifting in
conversation — control plane / controller / master / hub for one thing, substrate / foundation for
another — and a mesh you cannot name precisely is a mesh two people describe differently.
## How to read this page
## The mesh and its machines
A **domain** is an area of the mesh that owns a set of concepts: inside it each concept has one word,
and the domain decides what that word means. Other domains use the word as it is defined here and never
change its meaning. Every word is defined once, in the domain that owns it; a section lists the words
it uses from other domains under *Uses*, with no second definition.
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
just a machine that has joined; being one implies nothing about what it runs.
- **operator account** — the login name of the person who works on a node, stated on the node
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
resolved against this account's home and owned by it
([ADR 0176](../02-DECISIONS/0176-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
were last told; they simply cannot be told anything new.
- ~~master / slave~~, ~~hub / peer~~ — not used. The relationship is *controller and nodes*, and no
node is subordinate: a node applies declarations on its own and survives the control-node dying.
Two fixed lines follow an entry where they apply, and `00-META/checks/words.py` reads them:
## What runs the mesh
- *Not:* followed by struck-through words — the words this entry replaced. A struck word with no scope
is retired everywhere; *(hq)* retires it in this repository's prose only; *(tools)* in the
descriptions of the mesh's tools and seat verbs only. A retired word may be quoted, never used.
- *Identifier until renamed:* followed by a name in a code span — code that still carries an old name.
It may stand in a code span and nowhere else until the code is renamed, and then the line goes.
Nothing else on this page is struck through. A word that means different things in different domains
is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
## Words every domain uses
- **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.
- **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](../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
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](../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
checked by review, not by `words.py`.
- **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
it before the mesh — says machine.
- **node** — a machine the mesh has adopted and owns. A machine becomes a node when it joins
([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
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
implies nothing about what it 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
([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
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
([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)).
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](../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md)).
- **operator** — the person who runs the mesh, and the one the mesh talks to.
*Not:* ~~master~~ (hq)
- **person** — any human, as against the mesh acting unattended: *a person's word* releases a held
delivery, *a person* deletes a consumer's data. The operator is one; the word says that a human, not
the mesh, acted.
## Module — what a module is and declares
**Purpose.** To say what one module is, in the one document every other domain reads.
**Recorded in** the catalogue, one manifest per module. **Upstream of** every other domain; its words
are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0040, 0126, 0174, 0188,
0207, 0236, 0262.
**Uses:** module, seat, provision (Provisioning), data class (Data), health (Health and repair).
- **manifest** — the file a module is defined by (`module.json`): what it is, what it provides and
requires, the seats it claims, the resources it declares, its tools, its data and its health. Say
manifest in prose, not the file's name.
*Not:* ~~module definition~~ (hq)
- **resource** — one thing a manifest declares a node must have: a file, a directory, a service, a
container, a package, a bundle, an archive. A module **declares** resources; the verb *declares* is
this domain's and means what a manifest states.
- **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
it guards: the `mesh-controller`, `postgres` and `nats` modules claim the `mesh-controller`,
`mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md),
[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
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.
*Not:* ~~flavor~~
- **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
([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.
*Not:* ~~tunable~~ (hq)
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
lines survive every send and are given back when the module goes (ADR 0174). The other way a node
varies a module.
- **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
the tool runner. One module may declare several
([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
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
tools bundle to the tool runner. The harness is part of it, not a library of its own.
*Not:* ~~tools-sdk~~
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for every
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
the rest), `together`, or `record` (register the build, send it nowhere)
([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.
## Core — the mesh's own machinery
**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.
**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.
**Uses:** node, module, seat.
- **controller** — the component that decides what each node should be, holds the mesh's records, and
tells nodes over the bus. The relationship is *controller and nodes*, and no node is subordinate: a
node applies its declaration on its own and survives the control-node dying.
*Not:* ~~control plane~~, ~~slave~~ (hq), ~~mesh-control~~ (hq)
- **mesh-controller** — the module that runs the controller. It claims the `mesh-controller` seat at
mesh scope, which is what makes it singular.
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one per
mesh. It is not a separate kind of node — it is a node that additionally runs the controller (and,
today, the foundation). Lose it and the other nodes keep running what they were last told; they simply
cannot be told anything new.
- **node-engine** — the program on every node that applies what the controller declares: it receives
the node's declaration, writes the files, runs the services and containers, and reports what it did.
It is the engine, not a module: it owns no file's content, and every file it writes belongs to the
module that declared it. *Agent* is avoided because that word means a coding agent here. A record
written before the rename keeps the old name. "The host" is retired in this repository's prose only:
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.
*Not:* ~~host agent~~, ~~the host~~ (hq), ~~node host~~
*Identifier until renamed:* `mesh-host` — the repository, its binary and its service unit; the rename is
[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
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
([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.
*Not:* ~~node tools~~, ~~tool runtime~~ (hq)
*Identifier until renamed:* `node-tools` — the module, its unit and its bus account; the rename is
[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 is not a third thing beside the store and the bus — it *is* those two, named together.
*Not:* ~~substrate~~
- **genesis** — raising the foundation and the controller on an empty mesh, by the one installation
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`,
`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
(see [Homonyms](#homonyms)).
- **controller** — the component that decides what each node should be, holds the mesh's records,
and tells nodes over the broker. Replaces **"control plane"** (borrowed from networking's
control-plane/data-plane, and opaque here).
- **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
container and image it produces are `mesh-controller`.)
- **foundation** — the store and the broker, raised at genesis before any module system exists.
Replaces **"substrate"** (a biology metaphor that landed for no one). The foundation is not a
third thing beside the store and broker — it *is* those two, named together.
- **store** — the one postgres server. It holds the controller's own context databases
(`inventory`, `identity`, `licences` — a context owns its store, [ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md))
and every module's own database. One server, many databases — never one shared "mesh database".
- **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](../02-DECISIONS/0106-the-bus-is-nats.md)).
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
after the role rather than the server, so the server can change without the seat doing so. The `nats`
module holds it.
- **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
of their own the way something needs a database
([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.
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
contrast with it, which is backwards).
- **layer** — one of the four levels the mesh is built in, from the bottom: the node-engine, the
foundation, the controller, the surfaces. The records' `topic: the tiers` and `repos.md` say *tier* for
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
order of what it sent (the epoch, which a node reads before it accepts a declaration)
([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)).
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](../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 after
the *role* rather than the server, so the server can change without the seat doing so.
- **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 of their own the way something needs a database
([ADR 0127](../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))) — no seat, not foundation,
never raised at genesis, and a mesh that never installs it is complete.
## Placement — what runs on which node
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 contrast with it, which is backwards: the bus is the mesh's nervous
system and this is a module).
**Purpose.** To decide, send and apply what each node runs, and to say why.
**Recorded by** the controller's `inventory` database. **Upstream of** Provisioning, Connectivity, Data
and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 0252, 0261.
**Uses:** node, machine, module, seat, setting (Module), manifest (Module).
## What the mesh stores and serves
- **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
controller when that build registers the module, or ended with why when it does not
([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
value in a manifest.
- **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
and each answering the same, like `mesh-dns-resolver`
([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
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
holding the seat its family declares; *holding* is being the one — the login shell, the display session
— 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**.
- **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
`package` on `node-package-manager`, a `container` on `node-container-runtime`
([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.
- **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
is never called a declaration. The controller's verb `plan` previews a node's declaration.
- **send** — the controller giving a node its declaration. The controller's verb `push` asks for a
send (of one node, or of every node); prose says send. A git push and a phone's push notification are
other things (see [Homonyms](#homonyms)).
- **apply** — the node-engine making its node match its declaration; **reconcile** is doing so again
until nothing differs.
- **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
against this account's home and owned by it
([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.
- **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
run as the operator account. It cannot become root without a person, and the node-engine judges that on
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
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
([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.
## Provisioning — one module serving another
**Purpose.** To resolve which module serves a provision for which consumer, and to wire the two.
**Recorded by** the controller. **Upstream of** Data; downstream of Module, Placement and Identity.
**Decided by** ADR 0027, 0084, 0138, 0225, 0230, 0263.
**Uses:** module, seat, credential (Identity and access).
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat is a
role you occupy, and the two meet where a seat delivers a provision: occupying the seat is what makes
a module *the* provider of it.
- **provider** and **consumer** — the module that provides a provision, and the module that requires
it. Which of several providers serves a consumer is resolved
([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.
- **endpoint** — where a consumer reaches its provider; an assignment binds it and says how far it
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
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
`nfs-share`, provided by the holder of the node seat `node-nfs-server`, one share per folder
([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
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;
its absence is a state, *not present*, never a fault (ADR 0263).
## Identity and access — who may do what
**Purpose.** To issue, hold, rotate and check the credentials and grants by which modules, nodes, agents
and the operator act. **Recorded by** the controller's `identity` and `licences` databases and the vault.
**Upstream of** Provisioning, Change and delivery, and Operator and conversation.
**Decided by** ADR 0085, 0113, 0183, 0225, 0234.
**Uses:** module, node, operator, consumer (Provisioning).
- **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.
- **secret** — a credential or other value the vault keeps sealed and the node-engine unseals into a
file at apply ([ADR 0085](../02-DECISIONS/0085-a-secret-is-a-provision.md)).
- **vault** — the module that owns every secret (`mesh-vault`,
[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
([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
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.
- **licence** — a model-access account the licence manager holds and **binds** to a consumer, the
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
`release`, an identifier to rename.
- **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**.
- **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). 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
agent can run as that account: the control-node, and a node whose agents run under an account of their own
([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
**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
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.
**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**
(`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`)
([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** — 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
([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~~
- **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
(see [Homonyms](#homonyms)).
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by **version**.
Served by the **package-registry** (gitea). Only a builder talks to it.
- **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
**artifact store**, an OCI registry that holds every kind as content-addressed blobs
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by
**version**. Served by the **package-registry** (gitea). Only a builder talks to it.
- **artifact** — anything a build produces and the mesh delivers to a machine by **digest**: an
`image`, a 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
([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
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
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
has its own, and a change rebuilds the artifacts whose build source it touches
([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
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
holding another module's files.
- **catalogue** — what the mesh holds of every module, at which commit; and the repository the
modules live in. `mesh-catalog` and `catalog_modules` are identifiers and keep their spelling.
*Not:* ~~catalog~~ (hq)
- **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
([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](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)).
Its states are one table (`proposed`, `checked`, `ready` or `rejected`, `published`, `delivering`,
`held`, and the final six: `delivered`, `failed`, `superseded`, `stopped`, `retired` and `covered`;
[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](../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.
*Not:* ~~deployment~~ (hq)
- **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
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
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
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.
- **delivery plan** — what a delivery does to the mesh: its **build plan** (the modules it moves and
their dependents, in tiers), its **deploy plan** (per node, what it receives and what waits for 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.
*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](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)).
- **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
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
repository, tier by tier, one node first and judged at the first-node gate, then the rest (ADR 0236,
[ADR 0276](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)). 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
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.
*Not:* ~~rollout~~ (hq)
- **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)).
- **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`. Used in
this sense only.
- **rollback** — putting a node back on the build it ran before, by something other than the build being
judged (a witness, ADR 0236).
- **the lab**, **scenario**, **replay** — a real mesh raised to run a change against before it reaches
nodes; what a lab run declares; and a recorded failure run again to prove a fix (ADR 0016, ADR 0237).
Every node pulls from it. An image is one kind of artifact, and a module is not an image.
- These are two protocols, not one store being weak — see [ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md).
## Health and repair — what is wrong, and putting it right
## How modules relate to the mesh
**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.
**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.
**Uses:** node, module, node-engine (Core).
- **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 provision**, and its holder is then the mesh's answer for it when several modules
provide it ([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.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)). A seat has a **capacity**: a
capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders
coexist).
- **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 server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim
the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)).
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
what makes a module *the* provider of it.
- **health** — a module's or a core component's statement of how it is alive and ready, which the
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
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).
- **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.
- **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).
- **condition** — one open fact about something the mesh owns that is wrong, with a key and a severity;
raised and cleared only by observation
([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.
*Not:* ~~alert~~ (hq)
- **self-check** — the controller's own examination of the mesh, run on demand and on a schedule
(to-be 45 §4). Its verb is `doctor`, an identifier; prose says self-check.
*Not:* ~~doctor~~ (hq)
- **healer** — a registered response to one condition kind: its **repair** (the ordinary path again),
its **budget**, its back-off and its brake. A healer may not withdraw, delete or recreate data.
- **drill** — something broken on purpose to see the mesh raise and clear its condition; a drill is
never counted as a repair.
- **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
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.
- **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
component itself (to-be 45 §8).
## The surfaces
## Data — what the mesh keeps, and getting it back
**Purpose.** To know every item of data a module holds, how precious it is, and to keep it recoverable.
**Recorded by** the manifests' `data` declarations and every node's `node-backup` seat.
**Upstream of** Change and delivery and Health and repair. **Decided by** ADR 0232, 0233, 0235.
**Uses:** module, node, provider and consumer (Provisioning), person.
- **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
([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
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`
([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
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
**Purpose.** To make every node and module reachable by name where it should be, and unreachable where
it should not. **Recorded by** the controller. **Upstream of** Provisioning and Health and repair.
**Decided by** ADR 0007, 0117, 0138, 0223, 0226, 0247.
**Uses:** node, machine, assignment and endpoint.
- **private network** — the mesh's own encrypted network between its nodes, on which every node has an
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**.
*Not:* ~~overlay~~ (hq)
- **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.
- **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
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.
- **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
the holder of the node seat `node-resolver`, the machine's **own resolver**, which exists only where
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
endpoint on the private network.
- **packet filter** — what the mesh enforces on a node about which packets pass, the
`node-packet-filter` seat and its verb `rules`. A **found firewall** is a program the mesh found on a
machine and keeps retired or in force; *firewall* says that, and a wrapped program's firewall is its own.
- **intrusion prevention** and **ban** — refusing a source that misbehaved, and the refusal itself.
- **reach** — how far an assignment's endpoint may be reached from: the node, the private network, or
the outside (ADR 0138).
## Operator and conversation — the person the mesh works for
**Purpose.** To let the mesh and its operator talk: tell, ask, answer, and act only on an answer it can
trust. **Recorded by** the router (asks, warrants and the operator's identities).
**Downstream of** Health and repair, Change and delivery, and Identity and access. An authorising
answer acts in whichever domain asked, through that domain's own verb; the conversation owns the
asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
**Uses:** operator, person, proof (Identity and access), release (Change and delivery).
- **mesh MCP server** — the endpoint on a node's loopback through which every agent and person on that
node reaches the mesh: the MCP server named `mesh`, with its five tools `mesh_overview`,
`mesh_machine`, `mesh_search`, `mesh_describe` and `mesh_call`. It is the tool runner's loopback mode,
not a module of its own. "Console" suggested a terminal or a shell, and the module `mesh-console` it
once named no longer exists; "the tool bridge" and "the brain" named the predecessor's program.
*Not:* ~~console~~ (hq), ~~mesh-console~~, ~~tool bridge~~
- **mesh-cli** — the module on every node that places the operator's command, and the command itself:
`/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
line as the **controller's terminal** or as an ordinary call
([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
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
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](../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),
its insight of 2026-10-09).
- **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
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).
- **router** — the module that holds the `operator-channel` seat, orders the channels and checks every
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
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
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
([ADR 0253](../02-DECISIONS/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md)).
- **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
holds every ask, checks each answer's proofs and issues a **warrant**; the asker acts on it with its
own grants (ADR 0234, ADR 0259).
- **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,
published for that asker alone. The only thing an asker acts on (ADR 0259).
- **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,
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
participant, which answers from read verbs); anything else is **untrusted input** — data, never
instructions.
- **reference** — an opaque token in a message's words standing for a detail the content rule keeps out
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
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](../02-DECISIONS/0274-an-agent-proposes-a-shared-instruction-section-and-every-node-takes-it-only-on-the-operators-warrant-which-it-proves-itself.md)). 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](../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md)).
- **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).
## 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.
**Recorded in** this repository and the issue tracker. **Decided by** ADR 0019, 0080, 0244, 0275.
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation in `01-RESEARCH/`;
its closing into a decision and a design; a design given to a code repository; a documented workflow
in `00-META/process/`.
- **decision record** — one numbered file in `02-DECISIONS/`, never rewritten: **superseded** by a later
record, or corrected by a marked **progressive insight**. Always qualified in this repository: bare
*record* has other meanings (see [Homonyms](#homonyms)).
- **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;
one at the level of design or governance also has a report in `04-ISSUES/` under the same number
([ADR 0275](../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md)).
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)
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
## Homonyms
A word below means different things in different domains. In a governing document it is never bare;
it is always the qualified form. **Checked by review, not by `words.py`:** a word list cannot tell one
sense from another, so the reviewer looks for the bare word in the diff. Where a homonym is settled by
renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| Word | Sense | Say | Domain |
|---|---|---|---|
| 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 |
| | the sending of one batch of commits across nodes | **walk** (verb `plans`) | Change and delivery |
| | a step a person starts, like the bus's | **planned step** | Core |
| membership | the subjects a bus account is issued | **membership** | Provisioning |
| | an account in a group of the machine | **account group** | Placement |
| push | the controller giving nodes their declarations | **send** (verb `push`) | Placement |
| | a git push | **git push** | The record |
| | a phone notification | **push notification** | Operator and conversation |
| 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 |
| gate | the judgement on a delivery's first node | **first-node 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 |
| tier | a level of the mesh | **layer** | Core |
| | a step of a walk | **tier** | Change and delivery |
| | how much proof an ask needs | **assurance level** | Identity and access |
| ask | a request for the operator's input | **ask** | Operator and conversation |
| | an entry in the build queue | **build request** | Change and delivery |
| store | the one database server | **store** | Core |
| | the OCI registry | **artifact store** | Change and delivery |
| | a node's own copy of its last declaration | **last declaration** | Placement |
| record | a numbered decision | **decision record** | The record |
| | the knowledge base agents search first | **the record** (the `records` module) | Operator and conversation |
| | the upgrade policy that sends nowhere | `record` | Module |
| 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 |
| | the controller's own examination | **self-check** | Health and repair |
| | a file in `00-META/checks/` | **hq check** | The record |
| agent | a coding agent | **agent** | Operator and conversation |
| | the build seat's holder | **builder** | Change and delivery |
| the user | an account of a wrapped program, or of the bus server | **its account**, **bus account** | Identity and access |
| | a human | **operator** or **person** | words every domain uses |
| firewall | what the mesh enforces | **packet filter** | 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 |
| | a wrapped program's own deploy | its own word | — |
- **console** — the module (`mesh-console`) that puts the mesh's tools in front of whoever is on a
machine: an MCP endpoint on the machine's loopback for an agent, the same endpoint for a person. It
is assigned like any module, holds a credential the mesh minted, and calls tools under a grant its
manifest declares (`invokes`). Loopback is the authority boundary: whoever is on the machine owns the
mesh there ([ADR 0152](../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
Not "the tool bridge", "the brain" or "the MCP server" — those name the predecessor's program or a
protocol, and the console is a module.
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for
every one. A grant on the publish side and nothing else; a module that declares none calls nothing.
## How this page is kept
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
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a word
retired here may still appear there, and the *Not:* lines are how to read it.
**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
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
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)*
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.
Homonyms are checked by review.
A new name for an existing thing lands here first, in the same change that introduces it in code. A
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
term retired here may still appear there, and the mapping above is how to read it.
+1 -1
View File
@@ -11,7 +11,7 @@ updated: 2026-08-22
An agent states an intent — in words, from wherever they already are — and the mesh
carries it out. It takes the request in, works out what it means, does the work across
whichever nodes it needs, and returns a result. No terminal to open, no runbook to follow,
whichever nodes it needs, and returns a result. No console to open, no runbook to follow,
no remembering which node holds which thing.
Not automation, which does what it was told to do in advance. Self-control: the mesh
+3 -4
View File
@@ -62,8 +62,8 @@ 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
decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
`located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
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). What the checks cannot see — that code work actually started from a
research overview that does not say what it became. Run it with `records.py` and `index.py`
before any HQ merge. 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):
a feature branch exists because a design or an issue sent it.
@@ -74,5 +74,4 @@ YAML frontmatter (schemas in the section READMEs and playbooks). There are **no
files** and no decision ledger. **Every decision is a record in
[`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, the decision index included,
are generated on demand by the `hq-status` skill and never written to disk
([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)).
are generated on demand by the `hq-status` skill and never written to disk.
-7
View File
@@ -13,13 +13,6 @@
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.
**A record that puts work off** — a rename, a check, a case it does not handle — names the issue that
tracks it and the owner who will do 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). [`cycle.py`](../checks/cycle.py) holds every accepted record dated from
2026-10-09, and an older one corrected under the rule, to it.
3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter:
```yaml
+13 -46
View File
@@ -19,36 +19,14 @@ The knowledge base already holds the operational record and is indexed on sympto
folder is not a second copy of it. An issue here is a question HQ must **answer**, not an
incident someone must **clear**.
## The issue tracker holds the number
Every issue is opened in the **issue tracker**, the module `mesh-issues` holding the seat `issue-tracker` on the
control-node ([ADR 0275](../../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md),
[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.
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 tracker holds its number, title, notes and links; its status stays in the folder's
frontmatter until slice 3 of to-be 53 has imported the folders and been checked, and until then the tracker's
`move` refuses it, saying its status lives in its folder.
- **Wholly in the tracker** — every other issue. Its status, owner, notes and links live in the tracker from the
start, and nothing is written here.
No status is ever kept in two places for one issue.
## Steps
1. Open the issue in the tracker, through the mesh MCP server:
`mesh_call issue-tracker.open` with `title`, `report` (the symptom **as observed**, in plain terms, with the
evidence that it happened), optionally `located-in` (entries of the tracker's registered list of owners), and, for
an issue that gets a folder here, `record` — the folder's name without its number, `short-name`. The tracker
answers with the number `NNN`; a refused open spends none.
**An issue with a folder** then creates `04-ISSUES/NNN-short-name/00-report.md` under exactly that number, on a
branch:
1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
Work sits on unmerged branches for days, so two people both reading `main` allocate the same
number; it happened twice in one hour between two machines, and the second collision reached
`main` with every check passing (issue 155). `cycle.py` now refuses two records sharing a number,
which catches a collision but does not prevent one. Create
`04-ISSUES/NNN-short-name/00-report.md`:
```yaml
---
@@ -60,21 +38,11 @@ No status is ever kept in two places for one issue.
---
```
Then the symptom as observed, as in the tracker's report.
**An issue wholly in the tracker** is done at this step: what follows happens through the tracker's verbs.
2. Investigate. For a folder-backed issue, the trail goes in `01-diagnosis.md` in the same folder — dated,
including what was ruled out — and `status:` moves to `diagnosing`, then `located` once the owner is known. For an
issue wholly in the tracker, the trail goes in `issue-tracker.note` and the status changes through
`issue-tracker.move`, which refuses `located` without a registered owner in `located-in`.
3. Resolve. Set `status: resolved` (in the frontmatter, or through `issue-tracker.move` for an issue wholly in the
tracker), 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`
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
the commit before the fix and pass on it, or with `replay-none:` saying 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);
`cycle.py` checks it on a folder, and `issue-tracker.move` refuses the move without it).
Then the symptom **as observed**, in plain terms, with the evidence that it happened.
2. Investigate in `01-diagnosis.md` in the same folder — the trail, dated, including what was
ruled out. Move `status:` to `diagnosing`, then `located` once the owner is known.
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:`.
## Rules
@@ -85,9 +53,8 @@ No status is ever kept in two places for one issue.
- 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
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
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records
sharing a number.
- Renumbering a collision happens once, in the branch that lands last. Renumbering a branch whose
author is still pushing only moves the race.
- An issue whose answer is a general lesson should also be written to the knowledge base, so
the next person searching a symptom finds it. Both, not either.
- `status: wontfix` is legitimate and requires a sentence saying why.
-4
View File
@@ -24,10 +24,6 @@
## Rules
- 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. [`cycle.py`](../checks/cycle.py) checks the decision record
([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
request.
- Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout
+3 -3
View File
@@ -52,7 +52,7 @@ Three questions, answered from the machine:
the container env-file: [/var/lib/postgres/superuser.env]
```
The node-engine fills the hole on the machine, which is the only place both halves exist — the mesh
The host fills the hole on the machine, which is the only place both halves exist — the mesh
discarded the value
([credentials and their rotation](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)).
A **provisioner** is the exception: it reads a password file, so it mounts the `.secret`
@@ -89,7 +89,7 @@ Six attempts, one real bug. Recorded because the ratio is the lesson: **the mesh
time and the scaffolding was not.**
- A shape existed in the language and no host implemented it, so every declaration carrying one
was refused whole — correctly, and the node-engine said exactly that. **Nobody was reading the node-engine's
was refused whole — correctly, and the host said exactly that. **Nobody was reading the host's
log.** Read it first; it is the only place that says why a machine did nothing.
- A blind find-and-replace renamed a provision in quotes and missed the same word bare.
- A command was tested only for the invocations that should fail, so it rejected every real one
@@ -99,7 +99,7 @@ time and the scaffolding was not.**
## Rules
- **Read the node-engine's log before theorising.** A declaration that was sent and not applied says so
- **Read the host's log before theorising.** A declaration that was sent and not applied says so
there and nowhere else.
- **A failing test is kept, not skipped.** It is the reproduction.
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
+1 -1
View File
@@ -30,7 +30,7 @@ one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the tabl
| Repository | Tier | Holds |
|---|---|---|
| `mesh-host` | 0 | **exists.** The node-engine — one statically linked binary, requiring nothing present ([ADR 0005](../02-DECISIONS/0005-the-node-host.md)) |
| `mesh-host` | 0 | **exists.** The node host — 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-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 |
@@ -1,5 +1,5 @@
---
status: graduated
status: active
initiated: 2026-10-02
touches:
- 02-DECISIONS/0040-what-a-module-is.md
@@ -15,13 +15,7 @@ touches:
- 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
became: []
---
# 018 — The operator's machine as modules
@@ -69,7 +63,7 @@ and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalog
- [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.)*
**What this must settle before it graduates.**
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.
@@ -1,55 +0,0 @@
---
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.
@@ -1,34 +0,0 @@
---
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.
@@ -1,86 +0,0 @@
# 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.
@@ -1,48 +0,0 @@
---
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.
@@ -1,41 +0,0 @@
---
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.
@@ -1,37 +0,0 @@
---
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.
@@ -1,152 +0,0 @@
---
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.
@@ -1,94 +0,0 @@
---
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.
@@ -1,103 +0,0 @@
# 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.
@@ -1,197 +0,0 @@
# 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.
@@ -1,74 +0,0 @@
---
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.
@@ -1,141 +0,0 @@
# 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`.
@@ -1,123 +0,0 @@
# 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`).
@@ -1,51 +0,0 @@
# 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.
@@ -1,129 +0,0 @@
# 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.
@@ -1,77 +0,0 @@
# 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.
@@ -1,64 +0,0 @@
---
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)
@@ -1,103 +0,0 @@
# 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.
@@ -1,128 +0,0 @@
# 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.
@@ -1,169 +0,0 @@
# 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.
@@ -1,124 +0,0 @@
---
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.
@@ -1,60 +0,0 @@
# 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.
@@ -1,116 +0,0 @@
# 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.
@@ -1,128 +0,0 @@
# 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.
@@ -1,222 +0,0 @@
# 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)
@@ -1,186 +0,0 @@
# 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/
@@ -1,332 +0,0 @@
# 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.
@@ -1,117 +0,0 @@
# 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
@@ -1,247 +0,0 @@
# 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
@@ -1,238 +0,0 @@
# 09 — A proposed decision, and what the operator does now
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
## The recommendation, short
1. **The mesh holds a conversation with the operator over channels.** It sends messages and asks;
the operator answers or writes first. A message, an ask and an operator message are the three
things said ([06](06-a-conversation-with-the-operator.md)).
2. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder is a
module of its own, claiming a kind and declaring capabilities from a fixed, versioned vocabulary,
each with a contract test and a drill.
3. **Asks are general.** Yes or no, one of, text, number, date, acknowledge, each requiring its own
capabilities. They have timeouts, defaults, cancellation, batching and history. The answer returns
to the asker as an event, and an asker may wait.
4. **The output seat's holder is the router.** It chooses among the channels that satisfy a message or
ask, by **work context**: in a Telegram conversation, Telegram; at the desk, the desk; away, the
away channel. Unanswered, it escalates. **Context never lowers the bar** ([07](07-the-work-context-and-the-desk.md)).
5. **The desk is a full participant.** Notification actions and the launcher's prompt carry every
ordinary ask, with no account anywhere.
6. **Asks that authorise are a layer on top,** held by the controller. Three tiers (acknowledge,
approve, destroy), and three proofs the operator is answering (a verified Telegram sender, a code,
a security key's touch bound to the ask). The controller checks the channel's declared
capabilities from its own records and verifies codes and key assertions itself
([08](08-asks-that-authorise.md)).
7. **No agent authorises.** An agent asks; the operator answers where they are. On an X11 desk, where
an agent could click for them, only a code or a key touch counts. In a Telegram conversation, the
ask appears in that chat and is completed there, destroy included.
8. **Telegram is the first holder and the away channel.** It is free, on both phone platforms, needs
no server of the mesh's own, and is the only candidate that carries every tier
([04](04-telegram-as-the-first-holder.md), [05](05-the-other-holders-on-the-same-axes.md)).
9. **The watcher's watcher stays outside the seats,** with its own bot, on a machine that is not the
control node. A free outside dead-man service covers the rest. Pushover is the optional holder for
waking. Matrix is the self-hosted carrier once its push is measured.
10. **The built Telegram code needs D1–D4 fixed before it is configured**
([04](04-telegram-as-the-first-holder.md)).
## What the operator does
Minimal, in order. Steps 1–6 are possible today. The desk needs nothing from the operator: no account,
no bot.
1. **Make two bots.** In Telegram, open BotFather and run `/newbot` twice: one for the mesh's
conversation, one for the watcher. Keep each token out of agent sessions.
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
3. **Press Start** in each bot's chat. A bot cannot write first.
4. **Turn on Telegram's two-step verification,** if it is not on.
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once, reading the token from
a file, and take `message.chat.id` from your `/start`. It is the same for both bots.
6. **Give the mesh the values,** through the controller, never on disk:
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
its `telegram-chat-id`;
- assign the watcher to a machine that is not the control node, accept the watcher bot's token,
and set its chat id;
- push both machines, and run each module's test verb.
7. **Later, once built:**
- make a free dead-man check, and give its ping address to the mesh;
- enrol an authenticator, at a plain terminal;
- optionally, enrol a security key, to approve at the desk with one touch.
## What would change in the code (proposal, not built)
- **Now, in the output seat's holder and the watcher:** D1 (cut at 4096 characters), D2 (a reopening
is a new message), D3 (a clearing reaches the newest message), D4 (`disable_notification` for
warnings and clearings), then D5–D10.
- **Catalogue:**
- a claim carries `kind` and `capabilities`;
- a kinded bench refuses two holders of one kind;
- the vocabulary `channel-capabilities/1` and its contract tests;
- the shared library publishes on a seat's event subjects.
- **Router:**
- asks (`ask`, `ask cancel`, `asks`, `answer`, and the events `ask-opened`, `ask-answered`,
`ask-closed`);
- the work context, from presence events;
- the escalation chain.
- **Desktop:** `node-notifier.send` gains actions, and the holder emits the chosen one. The
screen-lock holder emits lock and idle changes.
- **Controller:**
- `authorises` on the verb definition;
- `authorise request`, `authorise answer`, `authorisations`;
- `retire approve` takes `expect`;
- the authorising verbs refuse direct calls without a code;
- the hand-act gains `via`, `requested-by`, `ask` and `proofs`;
- the operator's identities, the TOTP seed and enrolled keys as its own;
- a self-check probe: the away channel satisfies every tier.
- **Modules:**
- a `telegram` module holding `channel` and `intake` (long polling, buttons, replies, `ForceReply`
codes, a linking verb with a one-time deep-link code), placed where no agent runs as the operator;
- an agent bridge for operator messages;
- the dead-man ping.
## The tables
### Ask kinds × what a channel needs
| Ask | deliver | choice | reply | threads | Trust (only if it authorises) |
|---|---|---|---|---|---|
| a message (no answer) | ✓ | | | | |
| acknowledge | ✓ | ✓ | | | tier acknowledge: `exact-render` |
| yes-no | ✓ | ✓ or | ✓ | ✓ | tier approve or destroy, if it authorises |
| one-of | ✓ | ✓ or | ✓ (a number) | ✓ | as above |
| text, number, date | ✓ | | ✓ | ✓ | never authorises |
### Authorising tiers × proofs
| Tier | Needs | Telegram | Desk with a key | Desk without a key |
|---|---|---|---|---|
| acknowledge | `choice`, `exact-render` | tap | click | click |
| approve | + one proof | tap (P1) | click + touch (P3) | click + code (P2) |
| destroy | + two proofs, one of them P2 or P3 | tap + code (P1 + P2) | click + touch + code (P3 + P2) | carried by Telegram |
### Surfaces × declared capabilities
✓ declared, ~ conditional, blank not.
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | threads | operator-first | verified-sender | exact-render | code-factor | key-factor | private | reaches-when-mesh-down |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (placed apart from agents) | ✓ | ✓ | | | |
| desktop (dunst + launcher) | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | ✓ | ✓ | ~ (a key plugged in) | ✓ | |
| matrix (own server) | ✓ | ~ (push unmeasured) | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ~ | |
| ntfy | ✓ | ✓ | ✓ | ✓ | | ~ (http action) | | | | | ✓ | | | ~ | ~ (ntfy.sh) |
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | | | ✓ | ✓ | | | | ✓ |
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | ✓ | ✓ | | ✓ | ~ | | | ✓ |
| an agent's terminal | — | | | | | | ~ (relayed) | | | | | | | | |
| the console | — | | | | | | | | | | ✓ (own output) | ✓ | | | |
| the watcher's sender | ✓ | ✓ | | | | | | | | | | | | | ✓ |
### Where things go, by context
The full table is in [07](07-the-work-context-and-the-desk.md). In one line each:
- **in a Telegram conversation:** that chat;
- **at the desk:** the desk, or the away channel when the desk cannot carry it;
- **away:** the away channel;
- **night:** urgent only;
- **unanswered:** the next in the chain;
- **the desk locks:** it moves away.
## The proposed record
> **Title.** The mesh holds a conversation with its operator over channels that are seats, chosen by
> capability and work context, and an answer that performs an action is authorised by the controller.
>
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
> carrying the router, a Telegram client and a desktop adapter, and no answering back.
> - The operator expects many channels and many inputs.
> - The operator wants agents and modules to ask questions, not only for permission.
> - The operator wants the work context to choose the channel, and every authorisation completable on
> the away channel.
> - Today, actions that need a person are verbs any granted principal can call, agents included, and
> a hand-act records the calling principal, not the person.
>
> **Considered options.**
> 1. Channels as contributions to the output seat. A channel is running code with a secret and
> answers, not content a holder places.
> 2. One seat per channel kind. The router learns every seat.
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
> 5. A per-service module with its own approval or question path. Locks the conversation to one
> service.
> 6. **Kinded benches for out and in, a capability vocabulary, a router that holds the conversation
> and orders channels by work context, and an authorising layer held by the controller. Chosen.**
>
> For answers:
> - a webhook, or **long polling (chosen)**;
> - authorising actions called directly by the channel module, or **requested and answered through
> the controller (chosen)**;
> - trusting a desktop click, or **requiring a code or a key touch the controller verifies (chosen)**.
>
> **Decision.**
> - **The conversation.**
> - Two mesh seats are kinded benches: `channel` (send, edit, standing) and `intake` (one envelope
> per input).
> - Each holder is its own module, claims one kind, and declares capabilities from
> `channel-capabilities/1`, each with a contract test and a drill.
> - The output seat's holder routes messages and asks (yes-no, one-of, text, number, date,
> acknowledge) by required capability, then by work context, then by severity, escalating when
> unanswered. It says when nothing can carry something.
> - Asks have timeouts, defaults (never for an authorising ask), cancellation, a per-asker limit,
> batching and 30 days of history. Answers return to the asker as events.
> - Presence is current state on the bus, never a history, never in a message's words.
> - **Asks that authorise.**
> - The controller holds them: `authorise request` (anyone; never performs), `authorise answer`
> (intake holders only), `authorisations`.
> - It checks the caller's declared capabilities from its own records, the sender against its own
> list of the operator's identities, codes and key assertions itself, and the exact state's digest,
> single use and expiry.
> - Tiers acknowledge, approve and destroy require none, one and two proofs. The away channel must
> satisfy every tier, checked by the self-check, unless the operator chose otherwise for a tier.
> - No agent authorises. The authorising verbs refuse direct calls except as break-glass with a
> code.
> - The hand-act records `via`, `requested-by`, `ask` and `proofs`.
> - **First holders.**
> - Telegram is the first holder of both seats and the away channel, placed where no agent runs as
> the operator.
> - The desktop holds both for the desk.
> - The watcher's watcher stays outside the seats with its own bot, and an outside dead-man service
> is pinged by the self-check and the watcher.
>
> **Consequences.**
> - The output seat's holder loses its Telegram client to a module of its own and gains asks and the
> work context.
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
> - `node-notifier.send` gains actions.
> - The controller's verb definition gains `authorises`, and `retire approve` takes the set it
> approves.
> - Agents' grants lose authorising verbs.
> - To-be 45 §5 is amended: the operator answers.
> - Telegram sees the words, held to the content rule, and never a factor's secret.
>
> **How it is checked.**
> - **Catalogue tests:**
> - an unknown capability is refused;
> - two holders of one kind are refused;
> - each declared capability's contract test runs in its holder's build.
> - **Router tests:**
> - an ask goes only to channels whose capabilities satisfy it;
> - context reorders but never adds a channel;
> - an authorising ask never defaults;
> - a cancelled ask's copies are edited;
> - a fourth open ask from one asker is refused.
> - **Controller tests,** one per refusal of `authorise answer`:
> - from a non-intake principal;
> - from a kind that does not meet the tier;
> - from an identity not on the list;
> - with too few proofs;
> - with a used code;
> - with a key assertion over another ask's challenge;
> - with a stale digest;
> - a second answer.
>
> Also: a direct `retire approve` without a code.
> - **Self-check probe:** the away channel meets every tier.
> - **Live drills:**
> - an agent's question answered at the desk;
> - the same with the desk locked, answered on the phone;
> - an approve and a destroy on a test condition, answered on the phone, with the hand-acts read.
@@ -1,72 +0,0 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
became:
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 029 — The agent configured through its module
## What
Everything about the operator's coding agent that can be configured is configured through the agent
module's tools, and reaches the machines as **one plugin the mesh serves**:
- subagents, skills, slash commands, hooks, output styles and tool servers;
- the agent's settings and its instructions.
Each item is registered once, from any machine, and goes to one machine, several, or all of them,
including a machine that joins later. The mechanism is the one the module already uses for tool
servers: the registration is kept in the module's state on the bus, and each machine's instance writes
what applies to it. The operator chose the plugin route on the day this effort opened.
**Three scopes** (the operator's direction, the same day):
- **mesh:** in the mesh's plugin and the managed files, on every machine;
- **node:** the same places, rendered for one machine or a list of them;
- **home:** placed in the operator account's own agent directory on a machine, beside what the
person writes there by hand.
Instructions follow the same scopes: the mesh's piece, the node's piece, then further customisation per
machine. See [03](03-options.md).
## Why
The vendor gives the agent a machine-wide directory for its settings, its tool servers and one
instruction file, and **nothing machine-wide for skills, subagents, commands or hooks**. Those exist
only in a home or a project. So today they are copied into each home by hand, and they drift and go
stale. [01](01-what-is-configured-today.md) measures that on four machines.
A plugin is the vendor's own unit for carrying all of those at once. A machine-wide setting can name
a marketplace and enable a plugin from it. If the module serves the plugin and its own managed settings
enable it, the mesh gets a machine-wide place for everything the vendor left home-only, and the home
stays the person's ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
## What it touches
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
its managed directory, its state, and its tools.
- **Module state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
where registrations are kept, and which file content fits in a bucket.
- **Contributions** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)):
a module other than the agent's, say the forge's, wanting the agent to have a skill for it. That is a
contribution to the agent's seat, not a file it writes.
- **The managed settings key the operator sets** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
which a settings tool would write rather than a hand-composed settings layer.
## Documents
- [01 — What is configured today](01-what-is-configured-today.md): the evidence.
- [02 — What the vendor allows](02-what-the-vendor-allows.md): plugins, marketplaces and managed
settings, as documented, with sources.
- [03 — Options](03-options.md): where each kind of item goes, how it is registered and stored, and
the questions a decision has to answer.
- [04 — What was confirmed](04-what-was-confirmed.md): the checks, tried on one workstation.
@@ -1,51 +0,0 @@
# 01 — What is configured today
Measured on 2026-10-04 on four machines that run the agent module: two workstations, the control node
and a home server. The figures count what sits in each operator account's agent directory in its home,
outside the module's managed directory.
## What sits in the homes
| what | workstation A | workstation B | control node | home server |
|---|---|---|---|---|
| rule files (`rules/`) | 4 | 2 | 2 | 1 |
| skills of the person's own (`skills/`, beside the vendor's synced ones) | 6 | 2 | 2 | 2 |
| subagents (`agents/`) | 0 | 1 | 0 | 0 |
| slash commands (`commands/`) | 0 | 0 | 0 | 0 |
| plugin marketplaces known | 1 | 2 | 1 | 1 |
## What that shows
- **Six files the design says the operator removes are still on every machine.** To-be 36 §1 lists the
predecessor's rule files and skills and leaves their removal to the operator, "once, on each
workstation". On all four machines, the two predecessor skills are present, byte-identical to each
other:
- one that switches licences through tools that no longer exist;
- one that names the predecessor's forge.
So a session can still load a skill whose every instruction fails.
- **One instruction, three versions.** The predecessor's node-identity rule file is on three machines,
with three different contents. It was written per machine and then left alone.
- **Two instruction sets that contradict each other, loaded together.** On a workstation, one session
reads two sets of instructions:
- the module's managed instruction file says to search the mesh's records first;
- the predecessor's rule files in the home say to search the predecessor's knowledge base first,
through tools that are no longer served.
Both are loaded, and neither says the other is stale.
- **A subagent exists on one machine only.** A reviewer for module definitions was written on one
workstation. The other three machines cannot use it, and nothing says it exists.
- **Settings are per home, and so per machine.** The agent's auto-mode environment, the rules that
decide which actions the agent may take unasked, is written in one home's settings file. It
describes another organisation's cloud, and it answers for this mesh's forge only through a list of
trusted domains. When the agent refused a merge the operator had approved, the only lawful fix was
a managed key ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
The agent could not change its own settings, and nothing else in the mesh could either.
## What already works the way this effort wants
Tool servers. A server registered through the module's register tool is kept in the module's state
on the bus, keyed `all.<server>` or `<node>.<server>`. Every instance watches that state and writes what
applies to it into the managed tool-server file, and a machine that joins later takes it at its first
start. Today one server is registered there, for one workstation. That is the shape this effort extends
to everything else.
@@ -1,106 +0,0 @@
# 02 — What the vendor allows
Read from the vendor's documentation on 2026-10-04; the agent installed on the machines measured in
[01](01-what-is-configured-today.md) was a 2.1 release. Each fact names the page it came from. Where the
documentation is silent, this says so. A fact that a design rests on is to be confirmed on one machine
before it is built on (see [03](03-options.md), *What to confirm first*).
## What a plugin can carry
A plugin is a directory with a manifest in `.claude-plugin/plugin.json` and, beside it, any of:
- skills, slash commands and subagents;
- hooks;
- tool servers (`.mcp.json`) and language servers;
- output styles, workflows, themes and monitors;
- a `bin/` directory;
- a `settings.json`.
Its components are namespaced by the plugin's name, so a subagent `reviewer` in a plugin `mesh` is
`mesh:reviewer`, and it never collides with a person's own of the same name.
— *plugins/manifest-reference, plugins/loading (name conflicts)*
**What a plugin cannot carry:**
- **Settings.** Only two keys of a plugin's `settings.json` take effect: the default agent and the
subagent status line. The rest are dropped. — *plugins/manifest-reference, settings*
- **Permission rules.** Not documented as a plugin capability.
- **Instructions.** A `CLAUDE.md` at a plugin's root is not loaded, and the validator warns about it.
Instructions reach a session through skills only. — *plugins/manifest-reference, standard layout*
## Marketplaces, and a marketplace on the machine's own disk
A marketplace is a `marketplace.json` listing plugins and where each comes from. Its sources include:
- a relative path inside the marketplace;
- a forge repository, a git URL or a subdirectory of one;
- a package from a registry;
- an archive over HTTPS;
- the output of a command.
**A marketplace can be a directory on the machine.** Its plugins with relative paths are **loaded in
place**, not copied into the cache. An edit takes effect at the next session start, or at
`/reload-plugins` in a running session, and the plugin's version need not change.
— *plugins/marketplace-reference (marketplace sources), plugins/loading (in-place and copied plugins)*
A plugin from any other source is copied into a cache in the home, under
`plugins/cache/<marketplace>/<plugin>/<version>/`. — *plugins/loading*
## What managed settings do with plugins
These keys work in the machine-wide managed settings file — *plugins/org*:
| key | what it does |
|---|---|
| `extraKnownMarketplaces` | registers a marketplace on every session of the machine |
| `enabledPlugins` | `true` installs and enables a plugin; `false` blocks and hides it at every scope. The managed value outranks every other scope |
| `strictKnownMarketplaces`, `blockedMarketplaces` | allow-list or block-list of marketplace sources |
| `strictPluginOnlyCustomization` | refuses skills, subagents, hooks and tool servers that come from neither a plugin nor managed settings |
| `allowManagedHooksOnly` | runs only the hooks from managed sources |
| `disableSideloadFlags` | blocks loading a plugin from the command line |
| `syncClaudeAiPlugins` | stops plugins synced from the vendor's web account |
**Installed without anyone being asked.** Once the settings reach a machine, the marketplace is
registered and the plugins installed at the next session start. A non-interactive run installs them in
the background. Managed plugins do not wait for the workspace trust prompt. — *plugins/org*
## What the managed settings file honours besides
`permissions` (with its default mode and the switch that disables bypassing it), `autoMode`, `hooks`,
`env`, `model`, `statusLine`, `outputStyle`, `apiKeyHelper`, and the managed-only switches for permission
rules, hooks and tool servers. — *managed-settings*
That `autoMode` is honoured from the managed file is documented. That it changes what the agent
refuses on these machines is still to be seen ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
left that open).
## Tool servers: the exclusive file wins over a plugin's
When the managed tool-server file is present, as the module writes it, it is **exclusive**: only its
servers load. The vendor's web connectors load too when a managed key allows them. **A plugin's
`.mcp.json` servers are blocked.** — *managed-mcp (exclusive control)*
So a tool server registered through the module stays in the managed tool-server file. Putting it in
the plugin would silently stop it loading.
## Variables inside a plugin
- `${CLAUDE_PLUGIN_ROOT}`: the plugin's directory.
- `${CLAUDE_PLUGIN_DATA}`: a directory that survives updates.
- `${CLAUDE_PROJECT_DIR}`: the project's root.
These resolve in hook commands, tool and language server configuration, and the content of skills,
subagents and commands. A plugin's declared options (`userConfig`) can be marked sensitive; the agent
asks the person for them and stores them itself. — *plugins/manifest-reference (environment variables)*
## Reload
A running session does not see a changed plugin until `/reload-plugins` or a new session.
`/reload-plugins` reloads skills, subagents, hooks and servers. It does not restart monitors.
— *plugins/loading*
## Not documented
- a machine-wide directory for bare skills, subagents or commands. Only a plugin enabled by managed
settings puts them machine-wide;
- permission rules or instructions carried by a plugin.
@@ -1,159 +0,0 @@
# 03 — Options
The route is chosen: a plugin the mesh serves. What is left open is where each kind of item goes, how it
is registered and kept, and what the module does about what it finds in the homes.
## Scopes (the operator's direction, 2026-10-04)
The plugin is not the only place the module manages. **The agent's configuration is managed at three
scopes, and each item is registered at one of them:**
| scope | where it lands | reaches |
|---|---|---|
| **mesh** | the mesh's plugin, and the mesh's part of the managed files | every machine running the agent, including one that joins later |
| **node** | the same plugin and managed files, as rendered on that machine | one machine, or a list of them |
| **home** | the operator account's own agent directory on a machine (`~/.claude`) | that account on that machine |
Each machine renders its own plugin from the registrations that apply to it, so a node-scoped skill sits
in the same `mesh` plugin as a mesh-scoped one, on that machine only. The home scope places an item
where the person's own items live, without the plugin's prefix, as if written there by hand. The
difference is that the mesh knows it placed the item and can change or remove it.
**Instructions follow the same scopes.** The agent reads the managed instruction file first, then the
home's instruction file and its rule files, then the project's. These are concatenated, not overridden:
a later file does not cancel an earlier one, which is how the contradiction measured in
[01](01-what-is-configured-today.md) came about.
- **The mesh's piece** sits in the managed instruction file and is the same on every machine: how a
session on this mesh works, and the conventions.
- **The node's piece** sits in the same file, rendered per machine: its role, and instruction sections
registered for it.
- **Further customisation per machine** sits in the home: a rule file the module places, at the home
scope, beside whatever the person writes there by hand.
**What the home scope needs from [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).**
That ADR already lets the mesh own what it places in a home and hold the rest as found. So the module
owns each home item it placed, path by path, recorded in its state. It never writes, renames or
removes an item it did not place. A home item with the same name as one the person made is refused
at registration, never overwritten.
## Where each kind of item goes
[02](02-what-the-vendor-allows.md) puts a hard limit on the plugin: it carries skills, subagents, commands,
hooks, output styles and language servers, but no settings, no permission rules, no instructions, and
no tool server the exclusive managed file does not list. So there are four places, not one:
| kind | goes to | why there |
|---|---|---|
| skills, subagents, slash commands, output styles | **the mesh's plugin** | the only machine-wide place the vendor has for them |
| hooks | **the mesh's plugin**, with the scripts beside them | a hook's script can live in the plugin and be named through `${CLAUDE_PLUGIN_ROOT}`. A hook in the managed settings would need its script placed somewhere else |
| tool servers | **the managed tool-server file**, as today | the exclusive file blocks a plugin's servers |
| settings and permission rules | **the managed settings file**, beside the mesh's keys ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)) | a plugin's settings are dropped |
| instructions | **the managed instruction file**, in sections | a plugin's instruction file is not loaded |
The plugin is reached through two managed keys the module already owns the file for:
`extraKnownMarketplaces`, naming a marketplace directory the module writes, and `enabledPlugins`, set to
`true` for the mesh's plugin. Neither is the operator's to set. Like the attribution key, they are the
mesh's keys and outrank whatever the operator sets.
### Option A — one plugin
Everything the mesh serves is in one plugin, `mesh`, so every invocation reads `mesh:<name>`. That is
simple, and the name says where an item came from.
### Option B — a plugin per source
One plugin for what the operator registers, and one for what other modules contribute (below). An item
then says in its name whether a person or a module definition put it there. But the operator has two
prefixes to remember, and an item has two owners to ask about.
*Leaning:* A. Where an item came from belongs in the module's list tool, not in the item's name.
## Who registers an item
- **The operator, through the module's tools**, from any machine, for one, several or all of them. The
pattern is the tool-server register tool's, extended to every kind:
- `claude_code_<kind>_list`, `_register`, `_unregister` for skills, subagents, commands, hooks, output
styles and instruction sections;
- `claude_code_settings_get` / `_set` and `claude_code_permission_allow` / `_deny` / `_ask` /
`_remove` for the managed settings.
- **Another module, through the agent's seat** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)).
The forge's module wanting the agent to know how pull requests are made here contributes a skill. It
declares the contribution in its definition, and the controller renders it to the agent's holder on
each machine where both run. That depends on the agent module holding a seat; today it holds none.
The two meet in the one plugin. A contribution and a registration with the same name are refused at
registration, and the list tool shows the owner of each.
## Where a registration is kept
The tool-server registrations live in a key-value bucket the module declares
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)),
keyed `all.<name>` or `<node>.<name>`. Skills differ: a skill is a folder, and it can carry scripts
and reference files beside its main file. Every message on the bus is limited to about a megabyte.
1. **One value per item.** The item and its files go in one value, refused above a limit well under
the bus's. It is simple, it fits the module's existing state, and every item measured in
[01](01-what-is-configured-today.md) takes 20 KB or less on disk,
the vendor's synced skills aside. But a skill with a large reference file cannot
be registered at all.
2. **The bus's object store for files, the bucket for the item.** Large files are stored in pieces and
the item names them. Nothing in the mesh uses the object store yet, so ADR 0201 would need
extending.
3. **A repository on the forge.** The plugin is built from a repository, and registering an item is a
commit. This is reviewable and versioned. But a register tool would have to write to the forge,
and the forge would sit on the path to every machine.
*Leaning:* 1 now, with the limit stated and checked at registration. 2 when an item outgrows it. 3
mixes the operator's configuration into the code review cycle, which it does not need.
## Where the plugin is written
The module owns the managed directory, so the marketplace goes under it, written whole by the
module's code:
- the marketplace file;
- one plugin directory beside it.
It is loaded in place, so a change takes effect at the next session, or at `/reload-plugins` in a
running one. Nothing is copied into the home.
## What the module does about what it did not place
[01](01-what-is-configured-today.md) found stale predecessor files on every machine. The module did not
place those, so they are held as found (ADR 0182). It can:
- **report** them: a status tool lists the home's skills, subagents, commands and rule files, says which
the mesh placed, and names those that duplicate a mesh item or call tools no longer served;
- **import** one on request: `claude_code_<kind>_import` takes an item from one machine's home and
registers it at a scope the operator chooses. A skill written by hand on one workstation becomes a
mesh, node or home item in one call. Removing the original stays the person's act.
`strictPluginOnlyCustomization` would make home items stop loading altogether, and home-scoped items
with them. That is the operator's choice to make through the managed settings, not a default of the
module.
## What to confirm first, on one workstation
1. A directory marketplace named in the managed settings, with its plugin enabled there, loads with
no prompt, in place, in an interactive session and in a non-interactive one.
2. The plugin's skills, subagents and commands are offered under `mesh:`, beside the home's own
items, with no collision.
3. A hook in the plugin runs, with its script found through `${CLAUDE_PLUGIN_ROOT}`.
4. The exclusive tool-server file still loads the console, and a server in the plugin does not load,
as documented.
5. `autoMode` in the managed settings changes what the agent refuses (ADR 0213's open point).
6. The account can read the marketplace in the managed directory, which root owns.
7. The managed instruction file and a home rule file the module placed are both loaded, in that order.
## Questions a decision has to answer
- One plugin or one per source (leaning: one).
- How a registration is kept, and the size limit (leaning: one value per item, with a stated limit).
- Whether the managed settings are set through tools writing the module's state, or through the
controller's settings layer as ADR 0213 has it. If both, which one wins on the same key.
- Whether the agent module holds a seat, so that other modules can contribute to it.
- The three scopes, and the home scope's ownership rule: the module owns exactly the home paths it
placed, recorded in its state, and refuses a name the person already uses.
- Whether settings take the same three scopes. The home's settings file is the person's own, so it is
left out unless the operator chooses otherwise.
@@ -1,38 +0,0 @@
# 04 — What was confirmed
On 2026-10-04, on one workstation running the agent's 2.1 release, the checks [03](03-options.md)
listed were tried with a probe. The probe was a directory marketplace holding one plugin named
`mesh`, which carried:
- a skill, a subagent and a slash command;
- a session-start hook running a script in the plugin;
- a tool server in the plugin's own `.mcp.json`.
The managed settings were set through the agent module's `managed_settings`
([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
on that machine's layer only, and sent by a push. The module rendered them into the managed file
within seconds, without a restart.
| # | check | result |
|---|---|---|
| 1 | a marketplace named in the managed settings, with its plugin enabled there, loads with no prompt, in place | **confirmed.** A non-interactive session registered the marketplace and enabled the plugin at start, with nothing asked. The plugin was not copied into the home's plugin cache and is not listed among installed plugins: it is read where it lies |
| — | a change to the plugin needs no version bump | **confirmed.** A skill added to the plugin's directory after the first session was offered by the next one |
| — | a running session takes the plugin without being restarted | **seen.** An interactive session that was already running when the plugin was enabled offered its skills and subagent after the operator logged in again in that session, without a restart |
| 2 | the plugin's items are offered under its name, beside the home's | **confirmed.** `mesh:probe-skill`, `mesh:probe-agent` and the command `/mesh:probe`. A collision with a home item of the same name was not tried |
| 3 | a hook in the plugin runs, its script found through `${CLAUDE_PLUGIN_ROOT}` | **confirmed.** The session-start hook ran its script. The vendor's validator asks for the placeholder to be quoted |
| 4 | the exclusive tool-server file still loads the console, and a plugin's server does not | **confirmed.** The session started the console and the registered servers, and never the plugin's server |
| 5 | `autoMode` in the managed settings changes what the agent refuses | **very likely.** With a probe rule forbidding one harmless read-only command, a session that was already running had that command refused moments after the rule was rendered, though the refusal gave no reason. It also suggests the rule reached a running session without a restart. A clean check needs a session whose only difference is the rule; the agent may not start one in its own auto mode, so it is left to the operator |
| 6 | the account can read the managed directory, which root owns | **confirmed** by what already runs: every session reads the managed instruction file from that directory |
| 7 | the managed instruction file and the home's are both loaded, managed first | **confirmed** by what already runs: a session lists the managed instruction file first, then the home's instruction file, then each of the home's rule files |
## What this changes in the options
- The plugin route works as documented, with no file copied into the home. The module's managed
directory can hold the marketplace.
- **A tool server stays in the managed tool-server file.** Check 4 closes that.
- **Undoing a managed setting is not the agent's to do.** When the probe was over, the agent tried to
clear its own machine's settings layer, and its own auto mode refused that as self-modification.
Setting it had been allowed only because it made the agent stricter. So the tools that set the
agent's settings and permissions are tools the operator calls, and the agent calling them for
itself is refused by the vendor's own guard. A design must not assume an agent can tidy up after
itself.
@@ -1,29 +0,0 @@
---
status: graduated
became: [03-DESIGN/01-to-be/43-backups-against-mistakes.md, 02-DECISIONS/0214-backups-guard-against-mistakes-and-stay-on-the-machine.md]
initiated: 2026-10-05
touches: [04-ISSUES/242-the-mesh-has-no-backups, 02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md, 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md, 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md]
---
# 030 — Backups against our own mistakes
**What.** How the mesh keeps restore points of its data: what is copied, how often, how long it is
kept, full or incremental, where it lives, who runs it and how it is proven to restore.
**Why.** Issue 242: nothing in the mesh backs anything up, and when one misread file dropped every
database on the control node (issue 241), the newest copies were migration leftovers nine to twelve
days old, found by searching a disk.
**The scope is set by the operator, and it is narrow on purpose: mistakes, not disasters.** A
backup here protects against what a person, an agent or the mesh itself does wrong — a dropped
database, a deleted bucket, a bad migration, a file overwritten — and not against a disk dying or a
building burning. Losing data to a disaster is an accepted risk. That removes the off-site copy, the
cross-site transfer and the second key-holder from the problem, and leaves the part that would have
saved the night of issue 241.
**What it touches.** The store providers (each knows how to dump its own store consistently), the
host's scheduled steps (ADR 0053, built), the node-wide composition pattern a module's jail already
uses (to-be 31), and the operator's output channel (research 028) for saying a backup failed.
Documents: [01 — what the mesh holds](01-what-the-mesh-holds.md),
[02 — options and a proposal](02-options-and-proposal.md).
@@ -1,55 +0,0 @@
# 01 — What the mesh holds, measured 2026-10-05
Four machines: the control node (hosted, holds every public service), the home server (media, home
automation, a large ZFS pool), a workstation and a laptop. Sizes are apparent sizes, rounded.
## Nothing backs anything up
On every machine: no backup tool other than `rsync` and `pg_dump` is installed, no systemd timer and
no cron line mentions a backup, dump or snapshot. Every live data directory is on ext4 except the
home server's pool (ZFS), so a filesystem snapshot is available only there.
## The control node — the data that cannot be recreated
| what | size | how it changes |
|---|---|---|
| object store (file-sync service's files, photos) | 183 GB | slowly; files added, rarely rewritten |
| forge (repositories, attachments, its database) | 7 GB | daily |
| MS SQL Server databases | 5 GB | daily |
| mail (mailboxes; accounts in postgres) | 2 GB | continuously |
| postgres (forge, mail admin, identity, file-sync index, analytics, catalogue, licence manager) | ~2 GB | continuously |
| file-sync service's own directory, website, analytics | ~4 GB | slowly |
| MongoDB | 0.4 GB | daily |
| the mesh's own records (controller, vault, module state) | ~1.5 GB | continuously |
Recreatable and not worth copying: container images (210 GB), the artifact registry (40 GB — every
artifact is rebuilt from git), a 115 GB speed-test bucket and a 7 GB pre-migration object-store copy.
Free space: 833 GB on the filesystem holding the data, 2.9 TB on a second one.
## The home server
MS SQL Server 80 GB, postgres and a self-hosted backend platform ~3 GB, chat server 6 GB, home
automation, network controller and time-series data each under 2 GB, and the media services'
libraries (tens of GB, mostly cover art and metadata they re-fetch). The 89 TB media library is
replaceable by its nature and out of scope. Free: 31 TB on the pool, 453 GB on the system disk.
## The workstation and the laptop
The workstation has 142 GB under its services directory and 31 GB of container volumes; the laptop
3 GB. Mostly development; what among it is data nobody can regenerate is for each module to say.
## Between the sites
Control node to home server ~285 Mbit/s, home server to control node ~19 Mbit/s. Irrelevant now that
backups stay on the machine whose data they hold, recorded because it is why an off-site copy would
have been expensive.
## What issue 241 says about the requirement
- The mistake was noticed within hours. A restore point a day old would have lost a day.
- The restore had to go *beside* the live database, not over it, and that worked well.
- A copy of a live postgres data directory needed a throwaway server of the right version to read;
a logical dump would have restored directly.
- The data that survived was the data outside the dropped stores. A backup that lives inside the
store it protects — a database's own snapshot table, a bucket's own versions — dies with a drop.
@@ -1,74 +0,0 @@
# 02 — Options and a proposal
## Who decides what is backed up
1. **A central list** on the backup holder. Rejected: it is the attentiveness rule ADR 0030
rejected — a store added and not listed is silently unprotected.
2. **Each module declares its own data, a node-wide holder composes them.** The pattern of to-be 31
(a module declares its jail; the mesh composes them per node). A store provider declares *how*
to take a consistent copy (a dump command), a module with plain files declares *which* paths. The
holder composes every declaration on the node into one schedule. **Proposed.**
The data a module keeps in a database it gets from a provider is backed up by the provider, which
dumps every database it serves — so a consumer declares nothing, and a new consumer is covered the
day it is provisioned.
## Full or incremental
- **Databases: a full logical dump every time** (`pg_dump -Fc`, MS SQL `BACKUP DATABASE`,
`mongodump`). A dump restores with the store's own tool into a database beside the live one —
issue 241's recovery without the throwaway server — and is consistent, which a copy of a live data
directory is not.
- **Everything goes into one deduplicating repository per node** (restic or borg). Each night is a
complete restore point, yet only changed chunks cost space: the object store's 183 GB is copied
once, then each night adds what changed. This removes the full-vs-delta trade-off rather than
choosing a side.
Considered for the object store alone: the object store's own versioning with a lifecycle rule.
Rejected as the only copy — it lives inside the store, and a removed bucket or data directory takes
its versions with it.
## Where
On the same machine, outside every data directory the mesh manages, on a second filesystem where the
machine has one (the control node does). Not off-site: the scope is mistakes. The repository is
encrypted anyway (both tools require it); its key is a mesh secret (ADR 0085), so a person can
restore without the holder.
## How often, how long
- **Nightly**, at a quiet hour, as a scheduled step (ADR 0053).
- **On demand before a risky act** — a migration, a retirement, an operator's experiment — through a
verb; the act's own tooling can call it.
- **Kept: 14 daily, 8 weekly, 6 monthly.** A mistake is usually noticed within days, sometimes weeks
(a deleted file nobody opens). Six months bounds the space a slowly-noticed mistake needs.
Estimated cost on the control node: ~200 GB for the first night, a few GB a night after; well within
the second filesystem's 2.9 TB.
## Who runs it
A node seat, **`node-backup`**, held on every machine that has data by one module (named for the tool
it wraps). It receives the declarations, runs them, keeps the repository, and offers the verbs a
person needs:
- what is backed up here, and the last good night of each;
- take a backup now;
- restore one item **beside** the live one — a database to `<name>_restore`, a path to
`<path>.restored-<date>` — never over it. Swapping it in stays a person's act, as in issue 241.
## How it is proven
- Every run checks its own result; a failed or skipped night goes to the operator's output channel
(research 028), not only a log.
- Weekly: the repository's integrity check, and one database restored from the newest dump into a
throwaway instance and counted against the live one.
- A machine with data and no successful backup in 48 hours is a problem the mesh's status shows.
## Open questions
- restic or borg — both fit; restic is a single binary with no server, which suits a module.
- Whether the workstation and laptop take part at all, or only once a module there declares data.
- The mail spool is files and the forge has a dump command of its own; whether the forge's
repositories are worth backing up at all when every clone is a copy (issue 241 says the forge's
*database* is the part with no other copy).
@@ -1,66 +0,0 @@
---
status: graduated
initiated: 2026-10-06
touches:
- 00-META/how-we-build.md
- 01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md
- 01-RESEARCH/028-the-meshs-output-channel/00-overview.md
- 01-RESEARCH/019-a-warm-twin-of-the-running-mesh/00-overview.md
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
- 02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
- 02-DECISIONS/0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md
- 02-DECISIONS/0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md
- 03-DESIGN/01-to-be/06-the-controller.md
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
- 03-DESIGN/01-to-be/25-the-bus-on-nats.md
- 03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
became:
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
---
# 031 — A core that cannot fail silently
**What.** The principles the mesh's core must hold — the controller, the machine host, the bus, the
console and the path a change takes through them — so that it is fully diagnosable, monitors itself,
heals what it knows how to heal, and upgrades itself without a person standing by. And the mechanisms
and the order in which to build them.
**Why.** The operator, 2026-10-06: *"I still notice a lot of race issues, and commands being ignored, or
no feedback, no logs, no monitoring. Our mesh core setup must be fully diagnosable, with active
monitoring, self-healing, self-upgradeable, self-monitoring. The core principles must be very sturdy, no
ambiguities, clear plan of execution, fail-proof setup."*
The record bears it out. In the six days to 2026-10-06, 92 issue reports were opened. Of the 48 read here
as core failures, **every one was noticed because a person or an agent looked**, and **none was raised by
the mesh unasked**. Four faults came back through a different door after their first fix, because each
fix closed an instance and left its class open. One merge was skipped by the bus, and twenty-three over three
days have no matching action; a provider failed for twenty-three hours with only its own
journal saying so; seven databases were dropped on one unreadable file.
**What it touches.** The controller (its verbs, `status`, plans, a lease), the host (its apply and
report), the bus (its advisories and its upgrade), the console, the build path, the output channel of
research 028, and the self-healing intent of research 017, which this effort extends from the loops that
converge modules to the core that runs those loops. 017 deferred heartbeats, conditions and advisories
until the bus was NATS; it is now.
**Documents.**
- [01 — The evidence](01-evidence.md): 48 issues classified by class of failure (races, dropped
commands, no feedback, logs only, two writers, manual repair, self-upgrade, CI-versus-live, third
party), with time to detect and how each was noticed.
- [02 — Principles](02-principles.md): nine, each with what exists, what is missing and **how it is
checked**; and the candidates weighed and not kept.
- [03 — Mechanisms and roadmap](03-mechanisms-and-roadmap.md): conditions, watchdogs from a signals table,
a self-check (`doctor`), the output channel, healers with a hand-act log, a controller lease and report
sequences, staged core upgrades with rollback, a facts snapshot for merge checks, a lab replay of every
incident; five phases, ordered by risk removed; the five largest risks today.
**Graduated 2026-10-06.** The operator approved the conclusion the same day (*"do the research and
implement it"*). The nine principles and the plan became
[ADR 0227](../../02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md);
the mechanisms, the tables and the six phases became
[to-be 45](../../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md). The two measurements owed
— the signals table's bounds and a week of the hand-act log — are Phase 0's work there, not
preconditions of the decision.
@@ -1,202 +0,0 @@
# 01 — The evidence, classified by class of failure
Every issue report opened between 2026-09-30 and 2026-10-06 that bears on the mesh's core — the
controller, the machine host, the bus, the console, the build path — read in full and classified by
**the class of failure**, not by the component it was found in. A component view says "fix the host";
a class view says "the same thing is wrong in four places", which is what a principle is for.
## The count
| | |
|---|---|
| issue reports opened 2026-10-01 to 2026-10-06 | **92** (about fifteen a day) |
| of those (and a few from the days before), classified below as core failures | **48** distinct issues |
| classified in more than one class | 17 of 48 |
| a fault that **came back** after a fix of the same symptom | 4 chains: 200 → 265, 230 → 264, 257 → 261 → 267, 175 → 184 → 248 |
| noticed because a person or an agent looked — at a stalled plan, a wrong outcome, a journal, a test run by hand, a review | **48 of 48** |
| of those, the mesh's own answer carried the fact for whoever asked (a refusal, a `status` line, a push's output) | 4 (233, 244, 259, 263) |
| raised by the mesh to anyone, unasked | **0 of 48** |
The recurrences matter most. Each fix was correct for its instance and left the class standing, so the
same symptom came back through a different door days later. That is the measurement behind the
operator's mandate: point fixes are converging on the instances, not on the class.
## The classes
Nine classes, as the mandate frames them. The table under each is the evidence; *detected* is the time
from the fault's start to the moment anyone knew; *noticed by* is how.
### (a) Races: concurrent actors without an ordering
Two actors act on the same thing, and the order of arrival — not an explicit order — decides the outcome.
| Issue | The two actors | Detected | Noticed by |
|---|---|---|---|
| 204 | an outgoing and an incoming controller both sent declarations | 2 min | a person saw a module undone |
| 201 | a plan's push carried a controller digest older than its successor had written | 10 min crash loop | a person, nothing answered |
| 214 | the controller rebuilding itself; the outcome reached the old one or neither | 27 min | a person asked the plan twice |
| 219 | an older build finishing later replaced a newer one | hours | a person reading builds |
| 234 | seven declarations arrived during an eleven-minute apply; the newest was composed from a stale view and undeclared four modules | 8 min of removals | a person, the modules were gone |
| 254 | three plans for three merges ran at once, each asking the same builds | hours | a person, one plan stuck "building" |
| 256 | a first machine's report landed between a module's two sends and read as stale | 7 min | a person |
| 257, 261 | the machine's five-minute reconcile and a delivery took the apply lock in the wrong order | 5 min (257), 29 s visible undo (261) | a person |
| 267 | a reconcile's report queued behind a delivery's apply overtook it at the controller | until a hand push | a person |
| 265 | a push reloaded the bus's permissions while its own answer was still owed | 54 of 103 pushes over two days | a person, "did not answer in time" |
**What they share.** Every one is a receiver that kept *the last thing written* rather than *the
newest thing by an explicit order*. Declarations carry a sequence (issue 107); **reports do not**
(267 says so: "the report does not carry the declaration's sequence number, so the digest decides").
Plans are ordered by when they were made only since ADR 0218. Builds are ordered since issue 219.
Controllers have no epoch, so two instances can both act (204). Ordering was added one message kind at
a time, each after a race in it was seen.
### (b) Commands silently ignored, arguments dropped
| Issue | What was dropped | Effect |
|---|---|---|
| 244 | the console removed `node` from every mesh-seat verb's schema and call | `plan` could only refuse; **`push <one machine>` arrived empty and pushed every machine** |
| 259 | a named push's flush sent every machine a build a policy held back | a fault met on every machine at once, not one |
| 202 | a module whose setting was unset was *left out* of the machine | the resolver vanished from a declaration, no error |
| 188 | a refusal inside "who is on the network" dropped a machine | 40 min, every symptom pointed elsewhere |
| 231 | a misspelled placeholder written to a file as literal text | passes every check |
| 241 | an unreadable contributions file read as "nobody asks" | **seven databases dropped and recreated empty** |
| 255 | the journal verb read nothing and said "-- No entries --" | a refusal that reads as a quiet service |
| 246 | a runtime that answered late was treated as absent | the console said modules "run nowhere" |
**What they share.** A receiver that could not tell *nothing was asked* from *something was lost on the
way*, and chose a default. In 241 and 244 the default was the most destructive reading available.
### (c) Outcomes not fed back to the caller
| Issue | What the caller was told | What happened |
|---|---|---|
| 200, 265 | "did not answer in time" | the push ran; the answer was refused by the bus |
| 176 | the console's build tool neither waits nor registers | — |
| 229 | `plans` answers once in prose; nothing waits for a plan | an agent went round the mesh with `curl` |
| 230, 264 | a host stood aside for its successor and its report was cancelled | the plan waited for ever, reading `late: false` |
| 186 | the build machine dropped 26 of 43 asks; nothing counts asks against outcomes | inferred two hours later |
| 237 | `assign` answered "held" and "does not resolve for lack of it" in one breath | a person or agent would loop |
Since 2026-10-06 the controller answers within ten seconds and keeps every call's outcome under an id
(`calls`, issue 265). Read live the same night: **that log holds the last hundred calls in the
controller's memory**, so a controller restart — which every merge to the controller's own repository
causes — forgets every outcome it held. And `status`, a read-only verb, took **18 seconds** to answer,
twice in a row, so even the health question is answered only through the "still running, ask `calls`"
path.
### (d) Failures visible only as log lines
| Issue | Where it was said | For how long |
|---|---|---|
| 179 (recurred) | the identity provider's journal, every five seconds | **23 hours**, about 31 000 refused logins |
| 184 | the controller's log: slow consumer, heartbeats dropped | 24 min deaf |
| 187 | five faults in one day, each found by reading a container's log hours later | hours each |
| 183, 217, 265 | a `Permissions Violation` line from the bus client library | days |
| 233 | `status` said `refused`, correctly; nothing said it had lasted | 1.5 h |
| 243 | nothing: machines silently ignored lower licence generations | until a login waited three minutes |
| 248 | the controller's event loop stopped logging at 15:17 | hours; "status showed every plan done" |
| 266 | nothing: a merge was skipped by the bus | **23 unmatched merges over three days** |
| 238 | a ban list of 400 entries | the operator's own address banned for four weeks |
`status` printed its all-well sentence through 179, 248 and 266. ADR 0224 made the first of those break
it. The other two have no signal that `status` reads.
### (e) State that two writers own
| Issue | The two writers |
|---|---|
| 190, 222 | the runtime's configuration written by modules that are not the runtime, and by the controller |
| 201 | the controller seat's row written by a successor, read by a predecessor pushed back in |
| 239 | two definitions, in two repositories, held one module name |
| 245 | "behind" answered by a commit comparison beside the plan that already knows |
| 257, 261, 267 | the machine's state written by both the delivery and the five-minute reconcile |
| 250 | a merge announced by the forge's tool and by its poll |
| 179 | the identity provider's admin password: the mesh minted one, the database kept another |
The operator's direction on 245 is the principle in their own words: *"a second answer to the same
question is how the two came to disagree."*
### (f) Manual repair needed
Counted from the reports' own "what unblocked it" sections:
| Repair by hand | Issues | Times |
|---|---|---|
| a push by hand to unstick a plan waiting on a report | 230, 257, 264, 267 | at least 4 |
| a controller restart to recreate a missing object or let go of a held message | 208, 248 | 2 |
| a one-off program run as the controller, outside the service | 201, 248 | 2 |
| the identity provider's admin reset through its bootstrap command | 179 | 2 |
| a kept file restored on a machine by hand | 233 | 1 |
| a stuck plan closed by hand | 214, 254 | 2 |
| a ban lifted by hand | 238 | 1 |
| a consumer remade from now | 248 | 1 |
ADR 0224 (*detected automatically, repaired where safe, loud where not*) is the first rule that turns
one of these into a mechanism. 248's `broker consumer-reset` and 254's `plans close` turned two into
verbs a person runs. Every other row is still a hand on a machine.
### (g) Self-upgrade fragility
The core updates itself: the controller rebuilds and replaces itself, the host delivers its own
successor (ADR 0141), the runtime and the console are modules, and the bus is a module on the control
node.
| Issue | What the self-upgrade broke |
|---|---|
| 201 | the controller pushed back to an older build than its own successor's row |
| 204 | two controllers both sending during a handover |
| 213, 223 | the controller ran as a container, and a new mesh installed it so |
| 214 | the plan that rebuilds the controller lost track of it |
| 230, 264 | the host that stands aside loses the report of the apply that delivered it (fixed twice) |
| 245 | a rebuild of everything replaced the bus's container: **every runtime lost the bus for a minute** |
| 248, 266 | a controller restart is where merges go missing: most of 266's 23 lie in such windows |
| 217 | a refused announcement crash-looped nine runtimes, closing the path that would merge the fix |
A machine's first-in-line rollout (ADR 0218) protects modules. It does not protect the core from itself:
the health a plan waits for is "reported applied", which a controller that cannot plan, a host that
cannot report or a bus that drops messages can each satisfy. Nothing rolls back. The recovery in 201
was the mesh's own binary run by hand from the newer image.
### (h) Checks that pass in CI and fail live
| Issue | The environmental fact the check did not have |
|---|---|
| 177 | the store-backed tests are skipped by the quick check, and the mesh runs none of a module's tests |
| 236 | the host's declaration validation is not run by the catalogue check |
| 262 | musl takes an NXDOMAIN for IPv6 as final; glibc does not |
| 263 | the real machine names make a consumer's identity 23–26 characters against a bound of 20 |
| 202 | the controller's test against the real catalogue, run by nobody until that day |
| 228 | the host's removal has no case for a `user` — found by a review, not a test |
Each check was right about the world it was given. None of them was given the mesh's world: its machine
names, its catalogue, its host's validation, its C libraries.
### (i) Third-party bugs
| Issue | |
|---|---|
| 266 | the bus server's 2.10 release skips messages on a consumer with several filter subjects |
| 265 | a reload of the bus's authorization forgets every reply permission already granted (documented server behaviour, read from its source) |
| 262 | a resolver answering NXDOMAIN where NODATA is correct, met by musl's stricter reading |
The lesson of 266 is not "upgrade the bus" — it is that nothing compared *what was announced* with
*what was acted on*, so a dependency's bug was silent for three days. A defence in depth (watch the
outcome, not the transport) would have caught it whichever layer was wrong.
## What would have prevented or caught each class
| Class | Would have been prevented by | Would have been caught by |
|---|---|---|
| (a) races | every message ordered by its writer, stale refused by every receiver | a lab replay of the interleaving |
| (b) dropped | refusing an unknown or unreadable input by name | a schema walk over every verb |
| (c) no feedback | answer at once with an id; outcome kept durably | a watchdog on "asked and never finished" |
| (d) logs only | — | a condition in `status` and a notification |
| (e) two writers | one writer per piece of state | a registry of writers checked at composition |
| (f) manual repair | a healer for every repair done twice | a counter of hand acts |
| (g) self-upgrade | one machine first, health-gated, rolled back | a lab upgrade with a deliberately broken build |
| (h) CI vs live | checks fed the real mesh's facts | the same, before merge |
| (i) third party | pinning and testing the version that runs | an end-to-end count of announced vs acted |
The two columns are the principles of [02](02-principles.md). Read by count, **the "caught by" column
is the cheapest and widest**: a watchdog and a condition would have shortened most of the 48 from "a
person noticed" to minutes, whatever the cause.
@@ -1,247 +0,0 @@
# 02 — Principles for the core, each with how it is checked
Nine principles. Each is stated as a rule a reviewer can refuse a change against, carries the classes of
[01](01-evidence.md) it answers, says what exists already, and says **how it is checked** — the
repository's own rule ([how-we-build §5](../../00-META/how-we-build.md)): a rule that states no check is
indistinguishable from a wrong one.
They extend, not replace, the six of [research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md)
(a loop compares with what is; healing is the ordinary path again; a repair never destroys; nothing fails
silently; what cannot be fixed goes to an agent; correctness, not only liveness). Those are about the
loops that converge modules. These are about **the core that runs those loops**: the controller, the
host, the bus, the console and the path a change takes through them. 017 deferred heartbeats, conditions
and advisories until the bus was NATS. It is now, so that deferral has expired.
**The core**, for this document: the controller, the machine host and its launcher, the bus server, the
tool runtime and the console, the build seat, and the forge's announcer of merges. Everything a change
passes through before a module's own code runs.
---
## P1 — One writer per piece of state
Every piece of state the mesh keeps has exactly one writer, named. Anyone else who wants it changed asks
that writer; nobody writes beside it, and nobody computes a second answer to a question it already
answers.
- **Answers:** (e), most of (a). Issues 190, 201, 204, 222, 239, 245, 250, 257/261/267.
- **Exists:** the controller is the only writer of stream definitions (to-be 25); ADR 0222 §3 (the
controller writes no file a seat's holder owns); the collision check at composition (no two modules
declare one path, unit, name or package); the operator's direction on 245.
- **Missing:** a single writer for a *machine's applied state* (the delivery and the five-minute
reconcile both apply and both report — three issues in two days); a single *controller* (two instances
can both act during a handover, 204: nothing holds a lease); a single announcer per event kind (250
was found by counting duplicates by hand).
- **How it is checked:**
1. A **writers table** — state kind, its writer, where it is kept — is part of the to-be design, and a
test in each core repository asserts that the code paths that write each kind are the one named
(by a lint over the store's write calls and the bus subjects each component publishes on, the
latter read from the grants the controller composes: a subject two components may publish on is
refused at composition unless the table says it is shared).
2. **Live:** the controller holds a **lease** (a key-value entry with a revision) and every message it
sends carries the lease's epoch; a host refuses a declaration from an older epoch and says so. A
probe ([03](03-mechanisms-and-roadmap.md), the self-check) asserts one lease holder and no message
from a stale epoch in the last interval.
## P2 — Everything that changes state carries its writer's order, and every receiver refuses what is older
Declarations, reports, plans, builds, calls and announcements each carry `(writer, epoch, sequence)`.
Every receiver keeps the highest it has accepted per writer, and **refuses** — with a line in the mesh's
own words and a counter — anything older. Arrival order never decides.
- **Answers:** (a). Issues 201, 204, 214, 219, 234, 256, 257, 261, 264, 267.
- **Exists:** a declaration carries a sequence and a host keeps the newest (issue 107, to-be 25 §3); a
newer build wins over an older one finishing later (219); a newer merge's plan supersedes an older
one (ADR 0218 §3); a report about a declaration the mesh has moved past no longer replaces the stored
account (267).
- **Missing:** a **report carries no sequence** — the digest last recorded as sent decides, which is why
each of 256, 257 and 267 needed its own rule. No epoch on the controller. A plan's state carries no
revision, so two instances can both advance it (214).
- **How it is checked:**
1. A contract test per message kind, in the receiver's repository: deliver `n`, then `n−1`; the state
names `n` and a refusal is counted. Deliver from epoch `e−1` after `e`: refused. A new message kind
without such a test fails a check that lists every subject the component consumes against the
tests that name it.
2. **Live:** the refusals counter is a signal (P5): zero is normal, a burst is a condition naming the
writer that sent stale.
## P3 — Every command is acknowledged at once, and its outcome is kept where it can be read later
A call is answered within a bound the caller can rely on — with its result, or with an id. Its outcome is
kept **durably**, outlives the process that ran it, and can be read by id or waited on. Nothing is fired
and forgotten, and no answer depends on what the command does to the transport carrying it.
- **Answers:** (c). Issues 176, 186, 200, 229, 230, 237, 264, 265.
- **Exists:** since 265, a seat's call answers within ten seconds or says "running" with an id, `push`
answers before it acts, and `calls` keeps the last hundred calls and their answers; the build path
says "asked, not waited for" and ADR 0219 §3 makes every cancel/kill leave an outcome.
- **Missing:** `calls` lives in the controller's memory, so **a controller restart forgets every
outcome** — and the controller restarts on every merge to its own repository. Nothing waits on a plan
(229). Observed the night this effort began: the read-only `status` took 18 s, so the health question
itself is answered through the "still running" path.
- **How it is checked:**
1. A test that walks every verb the controller announces: each answers within `AnswerWithin`, with a
result or an id (already partly built for 265).
2. A test that restarts the controller between a call and the read of its outcome: the outcome is
still there.
3. **Live:** a probe calls `status` and asserts it answers *in full* within the bound; a call
`running` for longer than its verb's declared bound is a condition (P5).
## P4 — Nothing is dropped silently: an input that is unknown, unreadable or unmet is refused by name
A receiver that cannot read, place or honour an input refuses it and says which, where and why. It never
substitutes a default — above all never "empty" — for "I could not tell". An unknown argument, key,
placeholder, seat or subject is refused, naming it.
- **Answers:** (b). Issues 188, 202, 231, 241, 244, 246, 255, 259.
- **Exists:** the host's parser refuses unknown keys (ADR 0007, 0045); an undeclared setting or endpoint
is refused (ADR 0164, 0138); a verb takes only its declared arguments (ADR 0154) and, since 244, the
controller and the console refuse an undeclared one by name; a seat the mesh does not answer is
refused by name (ADR 0222 §1); ADR 0219 §3, "nothing dropped is silent".
- **Missing:** the rule is in a dozen records and in no principle, so each new reader re-meets it. The
destructive form — **an unreadable input read as "nothing asked", then acted on** (241) — has no
general guard.
- **How it is checked:**
1. Per component, a test that feeds each input reader an unreadable, malformed and foreign input and
asserts a refusal, never an empty result. A reader whose error path returns an empty value fails a
lint that the core repositories run (the shape is mechanical: an error branch that returns the
zero value of a collection).
2. Schema walks for every verb (244's tests) and every placeholder namespace (231).
3. **Destructive deltas are braked**: a reconcile that would withdraw more than a bound of what it
holds (one consumer, one module, a fraction set per provider) stops and raises a condition instead
(P7). Checked by a test that empties the input and asserts nothing is withdrawn.
## P5 — Every expected signal has a watchdog: absence is itself a condition
Every signal the core expects on a cadence or after an act — a heartbeat, a report after a send, a
plan's progress, a build's outcome after its ask, the controller's event loop taking something in, a
provider's standing, an announcement turning into an action — has a declared bound. Silence past the
bound is raised as a condition, naming what was expected, from whom, since when.
- **Answers:** (c), (d), (i). Issues 179, 184, 186, 187, 230, 243, 248, 257, 264, 266, 267.
- **Exists:** a machine's "last heard from — out of touch N m" in `node show`; ADR 0090's stuck machine
(three identical reports); ADR 0224's provider standing, shown with its silence after thirty minutes;
266's catch-up of merges not acted on after ten minutes (the first true *announced-versus-acted*
watchdog); ADR 0162's bound on a plan, which 230 found never fires (`late: false` for ever).
- **Missing:** a list of the signals, their bounds and their owners; the plan bound working; the event
loop's last-taken age (187); asks counted against outcomes (186); the bus's own advisories (slow
consumer, maximum deliveries, permission violations) read as observations instead of log lines.
- **How it is checked:**
1. A **signals table** — signal, emitter, cadence or trigger, bound, condition raised — kept in the
to-be design, and compiled into the controller. A test generated from it suppresses each signal in
turn and asserts the named condition is raised within its bound and cleared when the signal
returns.
2. **Live:** the self-check (P6) reports, for every row, the age of the newest signal, so a row that
never fires is itself visible.
## P6 — The mesh checks itself continuously, against live facts, and says what it found outward
The invariants the design states are probed **against the running mesh** on a schedule, not only in unit
tests. A violation is a **condition** — durable, with since-when, evidence and who can resolve it —
shown in `status` and **sent to the operator** through the output channel. The checker's own heartbeat is
watched from somewhere it does not run.
- **Answers:** (d), (h). Issues 177, 187, 238, 245, 253, 262.
- **Exists:** `status` itself (assembled from reports, as how-we-build §5 requires); 017's *condition*
shape; 028's output seat, researched only; individual live checks done by hand (262: "the live check
stays by hand, on each resolver"); 253's measurement before the collector's first run — the one time
in the window a check ran before the damage.
- **Missing:** a scheduled runner, a condition store, a channel out, and a watcher's watcher.
- **How it is checked:**
1. Every invariant in the to-be design that names a live check is a **probe** in the self-check's
registry; a check over the design documents counts invariants with a stated live probe against
those without, and the number may only go down.
2. The self-check publishes a heartbeat; a second machine's watcher raises "the self-check is silent"
through a channel that does not depend on the control node.
3. **Live, once:** a deliberately broken invariant on a lab mesh appears in `status` and as a
notification within one probe interval.
## P7 — A known failure heals itself, under a brake, and every repair is said
A failure that has been repaired by hand twice is a failure the mesh must repair itself: by running the
ordinary path again (017 P2), never by destroying (017 P3), with a budget and a back-off, and with one
line and one event saying what it did and why. When the budget is spent, or the only repair destroys,
it is a condition and a notification, not a retry.
- **Answers:** (f). Issues 179, 208, 214, 230, 233, 248, 254, 257, 264, 267.
- **Exists:** ADR 0224 §5, *detected automatically, repaired where safe, loud where not*, applied to the
identity provider's admin; the host's launcher rolls back once to known-good and then halts (ADR
0141); the provisioner's `holds` re-provisions what a backend lost (017/02); `broker consumer-reset`
and `plans close` as verbs.
- **Missing:** the general mechanism. The most frequent hand act in the window — **a push by hand to
unstick a plan waiting on a report** — has no healer: the controller could ask the machine to report
again (the machine knows what it applied) before waiting longer.
- **How it is checked:**
1. Every hand act on the core is done through a verb that records it (who, what, why) — the **hand-act
log**. Its count per week is a reported number; an act recorded twice for the same cause is a
condition asking for a healer.
2. Each healer ships with a test that induces its failure, asserts the repair and the event, and
asserts the brake after the budget.
## P8 — The core upgrades itself one machine at a time, health-gated, and rolls back on its own
A new controller, host, runtime or bus reaches one machine first; it is **healthy** only when the
self-check's probes for that component pass there (not merely when it "reported applied"); the rest
follow only then. A component that does not become healthy within its bound is rolled back to the last
known good **by something other than itself**, and the rollback is said. The component being replaced is
never the only witness of its successor's success.
- **Answers:** (g). Issues 201, 204, 213, 214, 217, 230, 245, 248, 264, 266.
- **Exists:** ADR 0218's one machine first, for modules, with "applied and current" as the gate; ADR
0141/0142's side-by-side host versions, known-good and the launcher's single rollback; ADR 0185's
controller serving what it can when it is behind its seat's row; 264's report kept on disk across the
hand-over.
- **Missing:** a health definition per core component; a gate stronger than "reported"; rollback for
the controller, the runtime and the bus; a lease hand-over between controllers (P1); a planned,
rehearsed path for the bus, which is still one process on one machine and whose next upgrade (266:
2.10 → 2.11) is one-way.
- **How it is checked:**
1. **Lab:** a deliberately broken build of each core component (one that starts and does nothing; one
that crashes; one that cannot reach the bus) is merged on a lab mesh. Each is rolled back without a
hand, the mesh ends on the previous build, and a condition and a notification say so.
2. **Live:** every core rollout leaves a record — first machine, health verdict, time to verdict,
rolled back or not — readable through `plans`.
## P9 — A check is fed the real mesh's facts before a change is merged
A check whose verdict depends on the environment — names and their lengths, the machines that exist,
the catalogue as it is, the host's validation, the C library, the server versions — runs against **the
mesh's real facts**, exported and anonymised, before merge. A dependency's version that the mesh runs is
the version its tests run.
- **Answers:** (h), (i). Issues 177, 202, 228, 236, 262, 263, 266.
- **Exists:** 266's `TestTheImageIsTheServerTestedHere` (the bus image's release equals the tested
server's); ADR 0223's composition test that renders the resolver's machine list; 202's test against the real
catalogue (run by hand).
- **Missing:** the export of facts; a merge gate that composes every real machine's declaration with the
change and runs the host's validation over it (which would have refused 236, 263 and 202 in their own
pull requests); a resolver test under musl as well as glibc.
- **How it is checked:**
1. The controller exports a **facts snapshot** (machines, names, assignments, seats, catalogue
commit — no secrets, no addresses) daily; the core repositories' merge check composes every machine
from it with the change applied and runs the host's validation; a pull request that makes any
machine fail to compose or validate fails its check, naming the machine's role and the module.
2. The snapshot's age is a signal (P5).
---
## Candidates weighed and not kept as principles
- **"Every invariant has a live probe, not only a unit test"** — merged into P6; it is how P6 is built.
- **"Environment-dependent checks run against the real mesh's facts"** — kept as P9; the third-party
case (i) folded into it, because pinning and testing the version that runs is the same act.
- **"Self-healing is the default"** — kept as P7 but narrowed to *known* failures, those repaired by
hand twice. A default of healing everything heals what is not understood, which is how a repair
destroys (241's reconcile was, in its own terms, healing).
- **"No loop blocks on long work"** (184, 248, 175) — not a separate principle: a blocked loop is a
signal gone silent (P5, the loop's last-taken age) and a design defect each owner fixes; stating it
as a principle adds a rule with no mesh-wide check.
- **"Clear plan of execution"** from the mandate — not a principle about the mesh; it is the roadmap in
[03](03-mechanisms-and-roadmap.md), and each phase there states its own verification.
## How the principles relate
P2 and P1 **prevent** the races. P4 **prevents** the silent drops. P3, P5 and P6 **catch** whatever the
first three miss, at the cost of minutes, not hours. P7 and P8 **repair**. P9 **moves** the catching
before merge. The order of the roadmap follows from that: catching first, because it is cheapest and
covers every class, including the ones nobody has met yet.
@@ -1,221 +0,0 @@
# 03 — Mechanisms and a phased roadmap
The principles of [02](02-principles.md) need few new things. Most of the parts exist in some form;
what is missing is the connective tissue that makes a fact the mesh already has reach someone without
being asked. This document names the mechanisms, then orders them into phases **by risk removed per unit
of effort**, each phase with deliverables and a verification that says it is done.
Effort is given in **focused working days** of one agent-and-operator pair, at the pace the record shows
(a located issue to a merged fix in under a day is common). The figures are for ordering, not promises.
## The mechanisms
### M1 — Conditions (P5, P6)
017's *condition*, built: a durable fact about something the mesh owns — what is wrong, since when, the
evidence, what was tried, who can resolve it, and whether it may clear itself. Kept in a key-value
bucket the controller writes (one writer, P1), keyed by subject (`plan/<id>`, `machine/<role>`,
`provider/<module>/<consumer>`, `core/<component>`). Raised and cleared by observation only; a person
can **silence** one for a stated time, never resolve it. `status` becomes, first, the list of open
conditions; the all-well sentence is "no open conditions". ADR 0224's provider standing is the first
condition kind and moves into it unchanged.
### M2 — Watchdogs from a signals table (P5)
One table, compiled into the controller, of every signal the core expects. Its first rows, each from an
issue in [01](01-evidence.md):
| Signal | Bound (to be measured, then set) | Condition raised | Issue |
|---|---|---|---|
| machine heartbeat | 3 × interval | machine silent (asleep is a declared state, ADR 0211) | 187 |
| report after a send | the machine's last apply duration × 3, at least 2 min | sent, not reported — then **ask the machine to report again** (M5) | 230, 257, 264, 267 |
| plan tier progress | per tier, from build and apply durations | plan stalled at tier N, waiting on X | 214, 230, 254 |
| controller event loop took something | 2 min while the stream has pending | controller deaf | 184, 248 |
| merge announced → plan made or "nothing reads it" | 10 min (exists, 266) | merge never acted on | 248, 266 |
| build asked → outcome | build's own declared timeout | ask lost | 186 |
| call `running` → finished | the verb's declared bound | call hung | 265 |
| provider standing repeated | 30 min (exists, ADR 0224) | provider silent | 179 |
| bus advisories: slow consumer, maximum deliveries, permission violation | any | bus refused or dropped X for Y | 183, 187, 217, 265 |
| self-check heartbeat | 2 × its interval, watched from a second machine | the watcher is silent | — |
| facts snapshot age | 2 days | merge checks run on stale facts | 263 |
The bus advisories are the cheapest row: the server already publishes them on its system subjects, and
the controller only has to subscribe (read-only) and translate each into the mesh's words, naming the
call or the consumer, as 265 now does for a refused reply.
### M3 — The self-check: `doctor` (P6)
A controller verb, `doctor`, and the same code run every few minutes by the controller itself. Each run
executes the **probe registry** — the live form of the design's invariants — and raises or clears
conditions. First probes, each an invariant that a person checked by hand in the window:
- every machine's declaration composes, and every host would accept it (236, 263);
- every holder of the mesh's resolver answers a machine name for IPv4 and NODATA for IPv6 (262);
- every seat on record has a live holder that answers (208, 218);
- every kept archive is held by a manifest (253 — the controller's collection command already reports it);
- exactly one controller holds the lease (P1);
- every durable consumer's position is near its stream's head (248's replay, 266's skip);
- no address the mesh owns is in a ban list (238);
- `status` answers in full within its bound (P3).
`doctor` with no argument answers the last run's verdict at once (P3) and, with `run`, runs now under an
id. Its own heartbeat is a signal (M2), watched from a second machine.
### M4 — The output channel (P6)
[Research 028](../028-the-meshs-output-channel/00-overview.md)'s seat, built minimally first: **one**
channel the operator chose (028 records it), plus the desktop notifier where the operator is. A condition
is sent when raised, once more if it lasts past a bound, and when it clears. Deduplicated by the
condition's key. The watcher's watcher (028's open question) is the second-machine watchdog of M3,
sending through a channel that does not pass through the control node.
### M5 — Healers (P7)
A healer is a registered response to one condition kind: its repair (the ordinary path again), its
budget, its brake, and the event it emits. First healers, all from hand acts in [01](01-evidence.md) §(f):
| Condition | Repair | Brake |
|---|---|---|
| sent, not reported | ask the machine to report what it last applied (it keeps it since 264); if that names another declaration, send again | twice, then condition |
| plan stalled on a superseded or finished wait | close the plan with its note (`plans close`, done by the mesh) | once |
| seat holder without its worker | raise the seat's objects again (208) | once per holder |
| consumer far behind on a history stream | `broker consumer-reset` (248) — **only** for the consumers the table marks resettable | once, then condition |
| provider admin refuses the mesh's secret | ADR 0224 §5 (exists) | exists |
And the **hand-act log**: every repair a person makes on the core goes through a verb that records who,
what and why. Its weekly count is the measure of P7.
### M6 — Order and epochs (P1, P2)
- The controller takes a **lease** in a key-value bucket before it acts and renews it; its revision is
the **epoch** every declaration and plan write carries. A starting controller waits for the lease;
the outgoing one stops sending when it loses it. That closes 204 and makes 201/214 detectable.
- A **report carries the sequence** of the declaration it is about; the controller keeps the highest
per machine and refuses older accounts by sequence, not by a digest lookup (256, 257, 267 become one
rule).
- **The host has one apply queue.** A delivery and the reconcile are two reasons to enqueue the same
act; the queue applies the newest declaration once, and makes one report (257/261/267 become
impossible rather than handled).
- Every receiver's refusal of something stale is a counted line (P2's live check).
### M7 — Staged, reversible core upgrades (P8)
- **A health definition per core component**, written as probes in M3's registry: the controller
answers `status` in bound and holds the lease; the host has reported its current declaration; the
runtime has announced and answers a PING; the bus has every stream and every durable consumer and
passes a request/reply round trip.
- **The gate:** ADR 0218's first machine is judged by those probes, not by "applied".
- **Rollback by a witness that is not the new build:** the host's launcher for the host (exists); for
the controller, the previous controller's process kept installed beside it, re-started by the host
when the new one does not take the lease in bound; for the runtime, the host's known-good the same
way. Each rollback is a condition, so it is said.
- **The bus is planned, not rolled.** A bus upgrade is a declared maintenance step: streams snapshotted,
the step announced as a condition while it runs, every consumer's position checked after (M3). The
one-way 2.10 → 2.11 upgrade of 266 is the first. Whether the bus should become a cluster of three so
that it can be upgraded live is a question for its own effort.
### M8 — The facts snapshot and the merge gate (P9)
The controller exports a facts snapshot (machines by role and name length, assignments, seats, catalogue
commit) to a place the build seat reads. The core repositories' and the catalogue's merge checks compose
every machine with the change and run the host's validation. The resolver module's tests run under musl
and glibc. A bus, store or library version the mesh runs is the one its tests run (266's pattern,
generalised).
### M9 — The lab replay (all)
[Research 019](../019-a-warm-twin-of-the-running-mesh/00-overview.md)'s warm twin, or a throwaway lab of
three containers, running **scripted replays of each incident** in the window: a reconcile due during a
push; a host self-update during its report; a bus reload during a call; a consumer with several filters
under mixed traffic; a missing consumer made with the server's default; an unreadable contributions
file; a controller rebuilding itself mid-plan; two controllers at once. Each replay asserts the
principle's outcome (refused stale, condition raised, healed, rolled back). They run on every merge to a
core repository.
---
## The roadmap
Ordered by **risk removed per day**. Detection comes first because it covers every class at once,
including the ones not met yet; prevention second; repair and staging third; the pre-merge and lab work
last because they are larger and pay off over months.
### Phase 0 — Finish what is in flight (2–3 days)
- Land and roll out the located fixes: 244, 264, 265, 266 (including the bus's planned 2.11 upgrade, done
as M7's first planned bus step), 267, 257/261.
- Make `calls` durable (a key-value bucket, bounded by count and age), and bring `status` inside its own
answer bound.
- Start the **hand-act log** now, before anything else, so every later phase is measured against a
baseline.
- **Done when:** the four located core issues resolve with their live checks; a controller restart
keeps `calls`; `status` answers in full within ten seconds.
### Phase 1 — The mesh says when it is wrong (5–8 days)
- M1 conditions (ADR 0224's standing moved into them); M2 watchdogs for the first eight rows; the bus
advisories subscribed and translated; M3 `doctor` with the first probes; M4 with one channel and the
second-machine watcher.
- **Done when:** on a lab mesh, suppressing each signal in the table raises its condition within its
bound and sends a notification; clearing it clears both. Live: a week of conditions read back, every
one either real or a bound corrected.
- **Risk removed:** every class in [01](01-evidence.md) moves from "noticed by a person, hours later" to
"said by the mesh, minutes later".
### Phase 2 — Order and one writer (5–7 days)
- M6: the controller's lease and epoch; a report's sequence; the host's single apply queue; stale
refusals counted.
- The writers table and the signals table written into a to-be design, with their compile-time checks.
- P4's lint for empty-on-error readers in the core repositories, and the destructive-delta brake in the
provisioner harness.
- **Done when:** the lab replays of 204, 257/261/267 and 241 end with "refused stale", "one report" and
"withdrawal braked" respectively; the contract test for every consumed subject exists.
- **Risk removed:** class (a), the largest by count, and the destructive half of (b).
### Phase 3 — Healers (3–5 days)
- M5's first healers and the rule that a repair done by hand twice asks for one (from the hand-act log).
- **Done when:** a lab mesh recovers from each induced failure in the healers table with no hand, says so,
and brakes after its budget. Live: a week in which the hand-act log has no repeat.
### Phase 4 — Core upgrades that roll back (8–12 days)
- M7: health definitions; the gate; rollback for the controller and the runtime; the bus as a planned
step.
- **Done when:** on a lab mesh, a deliberately broken build of the controller, the host and the runtime
is each rolled back without a hand, the mesh ends on the previous build, and the rollback is a
condition and a notification. Live: the next three core rollouts each record a health verdict.
- **Risk removed:** class (g) — the failures that take the control path itself down.
### Phase 5 — Checks before merge, and the replay suite (8–12 days, then ongoing)
- M8: the facts snapshot and the compose-and-validate merge gate; the libc matrix; versions tested as
run.
- M9: every incident in the window as a scripted replay, run on every core merge; each new core issue
adds its replay as its "how it is checked".
- **Done when:** the replays of 236, 262, 263 and 266 fail on the commit before their fix and pass after;
a new core issue cannot resolve without a replay or a stated reason why none is possible.
**Total, roughly six to eight weeks of focused work**, with the first visible change — the mesh saying
when it is wrong — inside the first two.
## The five largest risks today
Ranked by likelihood × damage, from the window's evidence and the mesh as read the night this effort
began:
1. **A stall nobody is told about.** A plan, a merge or a report that stops is noticed only by someone
looking (230, 248, 257, 264, 266, 267; issue 187 still open). Every release goes through a plan.
2. **A destructive act on a misread input.** 241 dropped seven databases on one unreadable file; 234
undeclared four modules from a stale composition; 245's advice rebuilt everything and cut the bus;
253's collector would have deleted every archive. There is no general brake on a large withdrawal.
3. **The core replacing itself with nothing to roll it back.** A controller build that starts but cannot
plan (201, 214) halts every later change, because the controller is what plans the fix. Only the
host has a launcher rollback.
4. **The bus as a single, un-upgradeable process.** It runs a release that skips messages (266), its
reload drops owed replies (265), replacing it cuts every machine off (245), and its next upgrade is
one-way and needs a restart.
5. **Concurrent actors with no lease or report order.** Several agent sessions, plans and reconciles act
at once; on the night this began, four named pushes, one per machine, started within two seconds. The digests
catch most of it now, one rule per message kind; the next message kind will not have one.
@@ -1,85 +0,0 @@
---
status: graduated
initiated: 2026-10-06
became: [02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md, 03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md]
touches:
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
- 03-DESIGN/01-to-be/18-building-a-module.md
- the module manifest
- the node-engine
- the release gate
---
# 032 — A module says how it is healthy
## What is investigated
Whether, and how, every module should declare **how its own health is checked**, as a field of its
manifest, so the mesh can judge a module the way it already judges its core parts.
Today the release gate ([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)) judges a
module on its first machine by what the mesh can see from outside: the declaration applied, no new
condition about the module or its machine since the send, and its tools answering. It does not see
whether the module's own service works. A container that applied cleanly and then restarts in a loop,
a web application whose port is open but whose pages fail, a database that accepts connections and
refuses queries — each passes the gate and is caught only through what it breaks later, if anything
notices at all. To-be 45 names this gap ("no container state in the gate").
The questions:
1. **What a health declaration says.** The kinds of check a module may declare (a container's own
health state, an HTTP request and the answer expected, a TCP connect, a command run inside the
service, a query, a tool of the module's own that answers "healthy"), its interval, its timeout,
how many failures in a row count, and a start period during which failure does not count.
2. **Who runs the checks, and where the result goes.** The node-engine on the machine, the node tools,
or the module itself; how the result reaches the controller (the report, an event, a seat verb); and
what the release gate, the self-check and the healers do with it.
3. **What the mesh already has to build on.** Container runtimes' own healthchecks, which many images
ship and which the mesh neither reads nor sets today; systemd's own state for services; the tools a
module already serves.
4. **What "healthy" covers.** Liveness (it runs), readiness (it serves), and whether a module's health
may depend on what it requires (a provider down makes its consumers unhealthy — said once, at the
provider, not once per consumer).
5. **The cost and the noise.** How often checks may run across all modules on a small machine, and how
a check avoids the single-sample flaw the self-check already met (issue 277).
6. **Migration.** How every catalogue module gets a declaration, what a module without one is judged
by, and whether `module check` should require one.
## Why
The mesh now rolls a module out on its own, one machine first, and puts the previous build back when
the first machine is not healthy. That promise is only as good as "healthy" is, and for a module it is
today judged from the outside. Making each module say how its health is checked turns "applied" into
"working", for the gate, for the self-check and for whoever asks.
## What it touches
The module manifest and `module check`; the node-engine, which would run or read the checks; the
release gate and the doctor probes of to-be 45; the healers, which may restart what stays unhealthy;
the operator's conversation (ADR 0234), which carries what stays unhealthy; and every catalogue module,
each of which would gain a declaration.
## Where it stands
Evidence gathered on the live mesh, options weighed and a decision drafted, 2026-10-07:
- [01 — The evidence](01-evidence.md): 125 catalogue modules, 68 of them running something long-lived
(49 a container, 19 a service); no manifest can declare a check and nothing reads one; 19 of 73
long-running catalogue containers carry an image check, two of which were wrong in the mesh's hands;
every restart count is 0 because a recreate loses it, and the runtime's event history is about a minute
long. Of seven recent incidents, liveness alone would have caught the crash loop, readiness the
eleven-hour silent web app, and only a module's own tool the refused identity-provider admin.
- [02 — Options](02-options.md): who runs the checks, where the result goes, liveness and readiness,
dependency-aware health, the field's shape and the migration.
- [03 — Recommendation](03-recommendation.md): liveness judged for every long-running resource at once;
readiness declared per resource in a field named `health` and run by the node-engine; the state in the
report, the condition raised by the controller on the second look, so the gate needs no new rule; a
provider down said once at the provider; a proposed decision text with how each rule is checked, and
the migration of the 68 modules.
**Graduated 2026-10-07** on the operator's word (*"yes, turn it into a decision"*), as
[ADR 0240](../../02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) and
[to-be 48](../../03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md). The record takes the proposed
decision, adding: a fifth state, `held`, for a resource under a maintenance step; a ceiling of five minutes on
grace plus failing looks, so a broken start is said inside the gate's bound; and the gate's *own health* reading
the stated health, so a resource still starting is not yet a pass.
@@ -1,196 +0,0 @@
# 01 — The evidence
Measured on 2026-10-07 on the live mesh of four machines (a home server, a control node, a workstation
and a laptop), read only: the three catalogue repositories at their trunk, the controller's and the
node-engine's source at their trunk, each machine's container runtime and service manager, and the
controller's own answers. Counts, not anecdotes. Machines are named by role.
## 1. What the catalogue runs
The three catalogue repositories hold **125 module manifests**: 113 in the main catalogue, 12 in the
media catalogue. The photos repository holds application code and no manifest; its two instances are
modules in the main catalogue.
Each manifest sorted by the **longest-lived thing it runs** (a container that stays up first, then a
service the manifest says is running, then the mesh's own process that stays up, then a bundle only):
| What a module runs | Modules |
|---|---|
| at least one container that stays up | **49** |
| no such container, but a service unit stated `running` | **19** |
| only its bundle — tools and handlers hosted by the node tools | **50** |
| only files, directories and packages | **7** |
Underneath:
| Resource | Count | Of which |
|---|---|---|
| container | **85** in 50 modules | 77 stay up, 6 run once (a step), 2 on a schedule; **71 distinct images** |
| service (an existing unit put in a state) | **31** in 21 modules | 26 stated `running`, 5 stateless (the machine's lifecycle) |
| process (the mesh's own code in a unit it writes) | **20** in 18 modules | 17 run once, 2 on a schedule, **1** stays up |
| bundle artifact | **101** in 99 modules | |
And what a check could be built from:
- **50** modules declare `listens` (an endpoint, a port, a protocol) — **48 of the 49** container
modules. A TCP or HTTP check has its target named already.
- **53** modules declare `tools`. **One** tool in the whole catalogue is a health tool by name
(`dbus_health`); **18** modules have a tool named `…_status`.
- **67** modules `require` something; the most-required provisions are `route` (36 consumers),
`x11-display` (15) and `postgres-database` (12). A database down is, today, potentially twelve
consumers failing at once.
- **No manifest declares a health check.** The container resource has no field for one — its fields are
image, environment, files, ports, volumes, arguments, names, networks, capabilities, logging, the
restart triggers and the run-once and schedule modes — and the node-engine passes the runtime no
health option. Whatever check runs is the image's own, run by the runtime by default.
## 2. What the images already ship, and what the runtime says today
Every container on every machine inspected — its state, its health, its restart count, and its
**image's** healthcheck from the image configuration:
| | |
|---|---|
| containers on the four machines | **115** (94 running) |
| of those, declared by the catalogue and running | **79 instances** of **73** of the 77 long-running containers (4 are assigned nowhere) |
| long-running catalogue containers whose **image ships a HEALTHCHECK** | **19 of 73 (26 %)** |
| …in how many modules | **7 of 45** modules with a running container (mail, the hosted database suite, a spreadsheet app, the chat client, a flow editor, the media server, the certificate authority) |
| …modules whose every container has one | **4** |
| running container modules with **no health state at all** | **38 of 45** |
| catalogue containers reporting `healthy` / `unhealthy` / `starting` | **19 / 0 / 0** |
| containers reporting `unhealthy` | 5, all exited, all on the workstation, all predating the mesh (none declared) |
**Two of the nineteen image checks were wrong under the mesh's own configuration** until a catalogue fix:
- the hosted database suite's studio — the framework binds the address the runtime puts in `HOSTNAME`, so
it answered only on its network address while its image's check asked `localhost`: *unhealthy for ever
while working* (fixed 2026-10-05);
- the flow editor — the image's check reads its settings from a path the module mounted elsewhere: *ran
fine but reported unhealthy forever* (fixed 2026-09-30).
So 2 of 19 shipped checks (≈ 10 %) gave a false *unhealthy* in the mesh's hands. Read blindly, they would
have failed two good builds at the gate.
The intervals the images chose range from **2 s to 60 s**: a database every 2 s, three web apps every
5 s, an API gateway every 10 s, the rest 30 s or 60 s. Start periods range from none to 360 s (the
antivirus). Retries from 3 to 20.
### The restart count the runtime keeps is lost
**All 94 running containers report a restart count of 0** — including the agent server that, per
[issue 268](../../04-ISSUES/268-letta-printed-its-passwords-into-its-log/00-report.md), had restarted about
a hundred times in a crash loop days before. The counter belongs to a container, and the node-engine
recreates a container whenever its declaration or a file it reads changes; the fix recreated it and the
history went with it. A restart count is only evidence if something outside the container keeps it.
### The runtime's event history is about a minute long
The runtime keeps its most recent ~250 events in memory. On the home server, a query for the last 30
minutes and for the last minute both answered ~250 events, **every one an `exec_*` event** — the image
health checks running (84 creates, 85 dies, 84 starts). A 24-hour query for container lifecycle events,
through the mesh's own `docker_events` tool, answered **zero**. The images' checks crowd every lifecycle
event out of the history within a minute. A container that died an hour ago leaves no trace there.
## 3. What the service manager says today
- **Failed system units:** 0 on three machines; 2 on the workstation, both mounts that the mesh does not
declare. One failed user unit on each of two machines, neither the mesh's.
- The mesh's own units (`mesh-ca-trust`, `mesh-filter`, the power units, the controller, the node tools,
the node-engine): all `active`, **`NRestarts` 0**.
- systemd already gives, per unit and for free: `ActiveState`/`SubState`, `is-failed`, `NRestarts`,
the time it entered its state, and — through a unit's own `ExecStartPost`/`WatchdogSec` where software
supports it — readiness. None of it is read by the mesh for a module's unit.
## 4. What the node-engine reports today
The node-engine's report (its `Report` message) carries: what applied and what failed per resource,
refusals, the declaration's digest and order, held and stray containers, reachable sockets on an adopted
machine, packet filters and the firewall found, windows (a scheduled step holding containers still), the
machine's profile, the engine's own build, outward links, rollbacks its witnesses decided, and whether it
reads `witness`. Separately, an `Alive` heartbeat with its interval.
**It carries no container state, no unit state and no health.** The controller's `node` answer for the
home server shows the consequence: its assigned modules, its strays, its filters and its capabilities —
and nothing about whether any of its 45 catalogue containers is running.
The node-engine has one judging mechanism already: the **witness** (to-be 45 §8) — a process's `witness`
field, `lease` or `ping` or `none`, judges a new build of the controller or the node tools and restores
the previous one when it is not healthy in bound. Only the two core processes use it.
A **run-once step** is already a health gate in practice: a step exiting non-zero fails the apply and
everything placed after it. **23** steps exist (6 containers, 17 processes); one of them, in the hosted
database suite, is a loop that waits up to five minutes for its analytics service's `/health` before the
services that need it start.
## 5. What the release gate judges a module by
The gate on a plan's first machine ([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);
`judgeHealth` in the controller) passes a module there when, on three judgings at least 40 s apart over at
least two minutes, within ten minutes of the send:
1. no witness on the machine put a core build back since the send;
2. the machine's last report is current and **applied** (failed or refused is broken);
3. **no condition** raised since the send names the module on that machine, or the machine as a whole
(issue 281 sorted the latter out);
4. for the controller, the node-engine and the node tools: their own health definitions (lease held and
ready; the engine's build reported; the node tools answering the bus);
5. for any other module **that declares tools**: the machine's node tools serve them.
That is the whole of it for a catalogue module. For the 49 container modules nothing in 1–5 looks at a
container: point 5 asks the node tools, which host the module's bundle, not its container. **A container
that applied and then crash-loops passes all five.** To-be 45's Phase 4 note says so: *"container state in
the node-engine's report, without which a container that crash-loops after its compose applied is seen
only through what it breaks"*.
None of the self-check's probes (D1–D13, the data and delivery probes, H-controller, H-engine, H-tools,
H-bus, DG) reads a module's container or unit state either.
## 6. The incidents, and what a declaration would have done
Every incident of the last ten days where a module applied and did not work, or looked as if it did not:
| Incident | What the gate and the self-check saw | Caught by, after | Would a declaration have caught it? |
|---|---|---|---|
| The agent server crash-looped: its database lacked the vector extension the provider never created (catalogue fix 2026-10-05) | applied; tools served (its bundle runs in the node tools, not the container); no condition | a person reading its log for another reason ([issue 268](../../04-ISSUES/268-letta-printed-its-passwords-into-its-log/00-report.md)), **about a hundred restarts**; the change readying it for the home server merged 2026-09-30, the provider's fix 2026-10-05 | **Yes, by liveness alone** — *running and not restarting* — inside the gate's ten minutes; an HTTP check on its declared `web` endpoint the same |
| A web app accepted TCP and answered no HTTP request, its database unreachable after a firewall change ([issue 145](../../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)) | *"all doing what they were told"* | a person, **after eleven hours** | **Yes, by an HTTP readiness check** within two looks (a minute at 30 s). **Not** by a TCP check — the port was open — and not by liveness |
| The identity provider's admin refused the secret the mesh minted; every consumer's client failed ([issue 179](../../04-ISSUES/179-an-adopted-identity-providers-admin-never-took-the-minted-secret/00-report.md)) | the server up and serving; its image ships no check | a person, the module having been broken since it moved to the mesh; **twice** (2026-10-01, again 2026-10-05) | **Only by a module's own check** — *the admin logs in* — which the module now runs itself and announces (ADR 0224). No container check sees it |
| The studio and the flow editor read unhealthy while working (§2) | nothing: the mesh does not read health | a person reading `docker ps` | The opposite case: **a check read without proving it would have rolled back two good builds.** A declaration must be owned by the module and proved before it is trusted |
| Two media managers refused a new recycle-bin folder; their settings step exited non-zero ([issue 279](../../04-ISSUES/279-a-folder-cannot-be-owned-by-the-account-a-module-runs-as/00-report.md)) | the apply failed → the gate failed, at once | the gate | Already caught: a step is a gate. A health declaration adds nothing here |
| The media server's event handler threw on an empty answer and was offered each event five times ([issue 276](../../04-ISSUES/276-a-handler-that-did-its-work-was-offered-it-five-times/00-report.md)) | — | the bus watchdog (S9), `max-deliveries` | **No, and it should not**: the server was healthy; handling an event is the event contract's, not health's |
| A provisioner runtime restarted several times at start until the overlay was up, then worked ([issue 058](../../04-ISSUES/058-a-provisioner-runtime-crash-loops-until-the-overlay-is-up/00-report.md)) | — | the lab | A warning: **a restart-counting check without a start period reads churn that stops as a crash loop** |
Of the seven: **two** a declaration catches that nothing catches today (the crash loop, by liveness; the
silent web app, by readiness), **one** only a module's own tool can catch (the admin), **one** shows what
an unproved check does wrong, and **three** are not health at all or are already caught.
## 7. Cost and noise
- **Reading every container's state on a machine**, health and restart count included: one `ps` of
38–49 containers took **17–21 ms**; one inspect of all of them **30–37 ms** (control node and home
server, five and three runs).
- **What the images' own checks cost today**, from the runtime's record of each check's start and end
(95 checks over 19 containers): **median 33 ms, slowest 138 ms**. On the home server they run
**77 checks a minute** — 8 containers, three of them every 2–5 s — for about **5 s of exec time a
minute**. On the control node, 11 containers at 30 s: 22 checks a minute, under 1 s.
- **The smallest machines in this mesh** have 12 cores (the control node) and 31 GB (the laptop); the busiest runs 45 catalogue containers.
One check per long-running resource at 30 s is **90 checks a minute** on the busiest machine — about
3 s of exec time a minute for in-container checks, a few hundred milliseconds for HTTP or TCP checks
the engine makes itself. The cost is not CPU; it is that every in-container check is an `exec` in the
runtime's event history, which on the home server is already one minute long.
- **Single-sample noise.** [Issue 277](../../04-ISSUES/277-one-unanswered-question-was-an-urgent-alert-nobody-could-read/00-report.md):
one DNS question unanswered on a machine starting twenty containers was raised urgent; thirty asked by
hand a moment later were all answered. Its rule — *a finding one look can be wrong about is raised on
the second look in a row* — is the runtime's own `retries` under another name; the images use 3 to 20.
The gate's own three passes 40 s apart are a third form of the same idea.
## 8. What this says
1. **Most of the catalogue has no health anywhere.** 38 of 45 running container modules, all 19
service-only modules and every bundle module have nothing that says *working* beyond *applied*.
2. **What exists is not read and not owned.** 19 image checks run, 2 were wrong in the mesh's hands,
and neither the report nor the gate nor the self-check reads any of them.
3. **The runtime's memory is too short to rely on.** Restart counts vanish with each recreate; the
event history is a minute long. Liveness must be observed and kept by the node-engine.
4. **Liveness alone would have caught the worst incident; readiness the longest; only a module's own
tool the identity provider's.** All three kinds are needed, and none is enough alone.
5. **The cost is small and the noise is known.** Tens of milliseconds a look; the two-look rule exists.
@@ -1,141 +0,0 @@
# 02 — Options
Each question of the [overview](00-overview.md), the options for it, and what the
[evidence](01-evidence.md) says about each. The recommendation is [03](03-recommendation.md).
## A. Who runs the checks
### A1 — The container runtime's own HEALTHCHECK, read by the node-engine
The image's check, or one the module sets, run by the runtime inside the container; the node-engine reads
the `health` state it keeps.
- **For:** 19 images already ship one; it runs inside the container's namespace, so a check of
`localhost` sees what the program sees; the runtime handles interval, timeout, retries and start period.
- **Against:** containers only — nothing for the 19 service-only modules, the one long-running process,
or anything a module's own tool knows. Every check is an `exec`, and on the home server the 77 a minute
already push every lifecycle event out of the runtime's history (01 §2). Two of 19 shipped checks were
wrong in the mesh's configuration; an image check the module never stated is a check nobody owns. The
runtime does nothing when a container turns unhealthy (it restarts only on exit), so the state is only
useful to whoever reads it.
### A2 — The node-engine runs every check itself
HTTP and TCP from the machine, `exec` through the runtime, a unit's state through the service manager, a
tool through the node tools.
- **For:** one runner and one reader per machine, for every hosting form; it keeps what the runtime
forgets (restarts across recreates, 01 §2); a check from outside the container tests the path a caller
takes, which issue 145 says matters; HTTP and TCP cost no `exec`.
- **Against:** the engine grows a scheduler; a check that only makes sense inside the container (a CLI,
a pid file) still needs an `exec`, which A1 does better.
### A3 — The module's own tool answers "healthy"
The bundle exposes a health tool; something calls it.
- **For:** the only kind that catches a failure of function — the identity provider's refused admin
(issue 179) — and the module knows what "working" means for it.
- **Against:** a bundle is hosted by the node tools, not by the service; a tool that answers proves the
bundle is up, not the server. One tool in the catalogue is a health tool today. And a module that judges
itself is the thing ADR 0227 rule 8 refuses for the core: *the component being replaced is never the
judge*. For readiness of function it is the only option; for liveness it must not be the only one.
### A4 — A mix, by kind (the shape the evidence points at)
The engine owns every check and every verdict. It runs HTTP, TCP and unit checks itself; it delegates an
in-container command to the runtime by setting the container's HEALTHCHECK from the declaration (so the
runtime's retries and start period do the timing) and reads the state; it asks a module's health tool
through the node tools. Liveness — running and not restarting — it observes for every long-running
resource with no declaration at all.
## B. Where the result goes
| Option | For | Against |
|---|---|---|
| **B1 — A field of the report**: per module, per long-running resource, a *state* (healthy, unhealthy, starting, unknown), since when, the failing streak and the restarts the engine counted | the report is how a machine states facts about itself; the gate already reads the last report; one place to read | a report is sent after an apply, not on a change — a container that goes bad at 03:00 waits for the next report |
| **B2 — An event on each transition** (`module.<m>.<machine>.health` changed) | immediate; the controller can keep the state | events are lost or replayed; an event alone is a sample, not a state |
| **B3 — A condition, raised by the controller** | the gate already fails a module on a new condition naming it on that machine (01 §5, point 3); the operator's conversation (ADR 0234) and the healers (ADR 0231) already act on conditions; the two-look rule and the content rule already apply | a condition is the controller's word, raised from something — it needs B1 or B2 under it |
| **B4 — A seat verb the controller calls** (`health <module>`) | always current | a pull per module per judging; a machine that is slow to answer reads as unhealthy |
B1 + B2 + B3 together is how the core already works: the engine states its state in the report and
emits the change; the controller keeps the last state per machine, raises the condition on the second
look, and clears it on the look that no longer sees it. That makes **the gate need no new rule**: an
unhealthy module raises a condition naming it, and point 3 already holds the gate on it.
## C. Liveness and readiness
- **Liveness** — *it runs*: a container running and not restarted within a window; a unit `active` and
not `failed`, `NRestarts` not climbing; a process up. Observable for every long-running resource with
no declaration. It alone catches the worst incident of the window (the crash loop). It needs a **start
period** and a **settle rule**, or it reads issue 058's churn-that-stops as a crash loop.
- **Readiness** — *it serves*: the declared check passes. Only the module can say what serving means.
- **Function** — *it does its job*: the identity provider's admin logs in. A module's own tool, and only
for what no endpoint shows.
Options: judge liveness only (cheap, no declarations, misses issue 145 and 179); judge readiness only
(misses nothing that readiness sees, but every module must declare before anything is judged); **judge
liveness everywhere at once and readiness where declared**, making the declaration required over a
migration. The last keeps the gate meaningful from the first day.
What the mesh **does** with each is a separate choice. Restarting a container that is unhealthy is what
an orchestrator's liveness probe does; the runtime here does not, and a restart hides the failure the
gate is meant to see. Options: the healers restart what stays unhealthy (ADR 0231's shape: act on what
observation raised, say whether it worked), or nothing restarts on health and the condition reaches a
person. The evidence has no case where a restart would have fixed anything — the crash loop was
restarting already.
## D. Dependency-aware health
A module requiring a provision fails when its provider fails. With 12 consumers of the database
provision and 36 of a route, a provider down would be a dozen conditions said once each, and a dozen
gates failed for something none of them did.
| Option | What it costs |
|---|---|
| D1 — Ignore it | twelve conditions for one fault; the operator learns which one matters by reading all of them; the gate blames the consumers |
| D2 — A consumer's check names the provision it exercises; when that provider is unhealthy on the record, the consumer's finding is **held under the provider's** — said as *waiting on* the provider, not raised on its own — and its gate waits rather than fails | one condition, at the provider; needs the controller to know which provider answers which consumer — it does: it composes the grants ([issue 181](../../04-ISSUES/181-an-assignment-does-not-record-which-provider-answers-it/00-report.md) is about recording it) |
| D3 — Order the judging: providers first, consumers only when their providers are healthy | simple, but a consumer broken on its own is not said while a provider is down, which is the moment it is most needed |
D2 is how issue 281 already treats a machine-level condition: what is about the machine is the
machine's, and is never pinned on the module the gate is kept on.
## E. The manifest field
Research may sketch a shape; a design doc may only name the field. Where the declaration lives:
- **E1 — On the resource** (`health` on a container, a process, a service): the check belongs to the
thing it checks; a module with three containers states three. Matches how `restart-on` and `witness`
already sit on the resource.
- **E2 — One per module** (a top-level `health`): one verdict, but a module of eleven containers (the
mail module) cannot say which one is wrong.
The sketch for E1 — a field named `health`, holding:
| Part | Meaning | Default |
|---|---|---|
| kind | `runtime` (the image's own check, adopted as is), `http` (an endpoint from `listens`, a path, the status expected), `tcp` (an endpoint from `listens`), `exec` (a command in the container), `unit` (the unit's own readiness), `tool` (one of the module's tools answering) | — |
| every | interval | 30 s; not under 10 s |
| timeout | how long one look may take | 5 s; under `every` |
| after | looks failing in a row before it is unhealthy | 3; **not under 2** (issue 277) |
| grace | after a start, how long failure does not count | 60 s |
| needs | the provision whose provider it exercises, for D2 | none |
An HTTP or TCP check names an endpoint by its `listens` name, never a port or an address, so a check
follows the machine's ports the way the endpoint does. A `runtime` kind is an explicit adoption of the
image's check, so a module that ships one *says* it does, and `module check` can prove it on a lab.
## F. Migration
- **F1 — Required at once** — every long-running resource declares, or `module check` refuses. 68
modules to touch before the next merge; nothing is judged until all are done.
- **F2 — Liveness at once, declaration required by a date** — every long-running resource is judged by
liveness from the first build; `module check` warns, then refuses after a stated date; a catalogue-wide
test counts the modules still without one, and the count only goes down.
- **F3 — Optional for ever** — modules without one are judged by liveness and tools served. The 38 of 45
container modules with nothing today would stay at liveness.
What a module without a declaration is judged by in F2 and F3: liveness of every long-running resource
plus today's five points (01 §5). A bundle-only module (50) and a files-only module (7) need no
declaration: they run nothing long-lived of their own, and the node tools serving their tools is their
liveness.
@@ -1,120 +0,0 @@
# 03 — Recommendation
From the [evidence](01-evidence.md) and the [options](02-options.md): the node-engine judges every
long-running thing a module runs — **liveness for all of them, at once, with no declaration**, and
**readiness where the module declares how** — states it in its report, and the controller raises it as a
condition on the second look, so the gate, the self-check and the operator's conversation act on it with
no new rule of their own. Every catalogue module that runs something long-lived declares its check
within a stated migration, and `module check` then requires it.
## Proposed decision text
Ready for graduation through playbook 02. The number is assigned then.
> **A module says how it is healthy, and the node-engine judges it**
>
> **Context.** The release gate (ADR 0236) judges a catalogue module on its first machine by what the
> mesh sees from outside: the declaration applied, no new condition about it or its machine, its tools
> served. None of that looks at what the module runs. Of 125 catalogue modules, 49 run a container that
> stays up and 19 a service unit; no manifest can declare a health check, the node-engine reports no
> container or unit state, and no probe reads one. 19 of 73 long-running catalogue containers have an
> image check the mesh never reads, two of which were wrong in the mesh's configuration. In ten days a
> container crash-looped about a hundred times and a web app answered nothing for eleven hours, both
> while every check passed.
>
> **Decision.**
>
> 1. **Liveness is judged for every long-running resource, with no declaration.** A container that stays
> up, a process that stays up and a service stated `running` are *alive* when running and not
> restarted more than once within the settle window after their grace period. The node-engine
> observes this itself on each tick and keeps the restarts it counted across recreates; it never
> relies on the runtime's restart count or event history. A resource held still by an open window (ADR 0189) is
> neither alive nor dead: it is said as held, and judged again when the window closes.
> 2. **A module declares how each long-running resource is ready**, in a field named `health` on that
> resource: one kind — the image's own check adopted by name, an HTTP request to a declared endpoint
> and the status expected, a TCP connect to a declared endpoint, a command in the container, the
> unit's own readiness, or one of the module's tools — with an interval (default 30 s, not under
> 10 s), a timeout under the interval, a number of failing looks in a row (default 3, **not under 2**),
> and a grace period after a start. An endpoint is named by its `listens` name, never by a port or an
> address. A check of function that no endpoint shows is a module's own tool, and only in addition to
> a check the module does not run itself.
> 3. **The node-engine runs every check and owns every verdict.** HTTP, TCP and unit checks it makes
> itself; a command in the container it hands to the runtime as that container's healthcheck and reads
> the state; a tool it asks through the node tools. Nothing else on the machine judges a module.
> 4. **The state goes in the report, its change on the bus, and a condition is the controller's.** Each
> report carries, per module and long-running resource, a state — healthy, unhealthy, starting,
> unknown — since when, the failing streak and the counted restarts; each transition is emitted as an
> event. The controller keeps the last state per machine and raises `module.<module>.<machine>`
> unhealthy as a condition when two consecutive states say so, and clears it on the first that does
> not. The release gate, unchanged, holds a module on a condition naming it; at its bound the build is
> put back.
> 5. **A provider down is said once, at the provider.** A check names the provision it exercises. While
> that provision's provider is unhealthy on the record, the consumer's finding is held under the
> provider's condition — said as waiting on it — and the consumer's gate waits rather than fails.
> What a consumer finds while its provider is healthy is its own.
> 6. **Nothing is restarted for being unhealthy.** The runtime restarts what exits, as now. A condition
> reaches a person or a healer (ADR 0231); a healer that restarts on health is its own decision.
> 7. **A declaration is proved before it is trusted.** `module check` refuses a `health` field that names
> an endpoint the module does not declare, an interval or count below the floor, or a tool the module
> does not serve. A lab bed applies every changed declaration and requires it healthy within its grace;
> an image check adopted by name is proved the same way, because two of nineteen were wrong.
> 8. **Every catalogue module that runs something long-lived declares one.** `module check` warns from
> the decision and refuses a long-running resource without `health` after the migration's date. A
> module running nothing long-lived — its bundle only, or files and packages — declares none: the node
> tools serving its tools is its liveness, as the gate judges today.
>
> **Consequences.** Liveness alone, from the first build, would have caught the crash loop inside the
> gate's ten minutes. Readiness would have caught the silent web app in a minute instead of eleven hours.
> The identity provider's refused admin needs the module's own tool, which it now has. The node-engine
> grows a small scheduler and a report field; the controller a condition kind; the gate nothing. A
> machine of 45 containers spends tens of milliseconds a look reading state, and about 3 s a minute on
> in-container commands at the default interval.
## How each rule is checked
| Rule | Checked by |
|---|---|
| 1 Liveness without declaration | node-engine unit tests over a fake runtime and service manager: a container recreated keeps its counted restarts; a restart inside grace is not counted; two restarts within the settle window after grace make it unhealthy. A lab bed that replays the crash loop (a container whose program exits at start) fails the gate within its bound |
| 2 The field and its floors | `module check` refuses each out-of-range part, with a test per refusal; a catalogue-wide test parses every `health` field |
| 3 The engine owns the verdict | a test that a declared command becomes the container's healthcheck and nothing else sets one; a test that an HTTP check dials the endpoint's current port after a port change |
| 4 Report, event, condition | controller tests: one unhealthy state raises nothing and is listed unconfirmed; two raise; a healthy state clears; the gate holds a module on that condition (an existing gate test, extended with this condition's kind). A lab replay of issue 145 — a database made unreachable — raises the consumer's condition within two looks |
| 5 Said once at the provider | a controller test with one unhealthy database provider and three consumers failing: one condition, at the provider, the consumers listed as waiting; the consumers' gates wait, not fail |
| 6 No restart on health | a node-engine test that an unhealthy container is not restarted, recreated or stopped |
| 7 Proved before trusted | the lab bed's run of every changed declaration on every catalogue merge; the replay of the studio's false *unhealthy* (a check that asks `localhost` while the program binds elsewhere) fails the bed, not a machine |
| 8 Every long-running module declares | a catalogue-wide test that counts the modules with a long-running resource and no `health`: it may only go down, and is zero by the date; after it, `module check` refuses |
## The migration
**Today:** 68 modules run something long-lived (49 with a container, 19 with a running service only);
57 run nothing long-lived and need no declaration.
1. **Build first, without declarations.** The node-engine's liveness, the report field, the events, the
controller's condition and its two-look rule, and the dependency hold. From this step every
long-running resource is judged by liveness. The catalogue-wide counter starts at 68.
2. **The seven modules whose images ship checks** adopt them by name — and, for the two that were
wrong, the fixed configuration is what the lab proves. 19 of their containers are covered at once.
3. **The other 42 container modules, and the containers without an image check in three of the seven,** declare an HTTP check on their declared endpoint where they serve
HTTP, TCP where they serve something else, a command where neither shows readiness. 48 of 49
already declare the endpoint the check needs.
4. **The 19 service-only modules** declare the unit's own readiness, or a TCP check where the unit
listens; most are machine software (a resolver, a time daemon, a session manager), where `active` and
not `failed` is already the honest answer.
5. **Modules whose function no endpoint shows** add a tool check: the identity provider (its admin logs
in), the database provider (it can create in a consumer's database), the broker. Identified as they
are met, not in advance.
6. **The date:** when the counter reaches zero, or six weeks after step 1, whichever is first. From then
`module check` refuses a long-running resource without `health`.
**What a module without a declaration is judged by, until then and for ever if it runs nothing
long-lived:** liveness of everything it runs that stays up, and the gate's five points as they stand —
applied, no witness put it back, no new condition naming it or its machine, the core's own definitions,
its tools served.
## What this does not decide
- Whether a healer restarts what stays unhealthy (rule 6 leaves it to its own record, under ADR 0231).
- Health for scheduled work — whether the last scheduled run succeeded is data the windows of ADR 0189
already carry, and belongs to that record.
- Health of what a module's events do (issue 276) — the event contract's, and the bus watchdog's.
- The core's own definitions (to-be 45 §8), which stand; a core component may later declare its own
through the same field.
@@ -1,79 +0,0 @@
---
status: graduated
initiated: 2026-10-07
became:
- 02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md
- 03-DESIGN/01-to-be/50-split-dns-on-a-machine-with-a-vpn-client.md
touches:
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 02-DECISIONS/0117-a-machines-uplink-is-a-seat.md
- 03-DESIGN/01-to-be/08-connectivity.md
- the node-uplink seat and its holders
- the node-engine's reconcile of a file another program rewrites
---
# 033 — Split DNS with a VPN client
## What is investigated
How a machine of the mesh resolves three kinds of name at once — the mesh's (`*.internal`), a
corporate network's reached through a VPN client, and public ones — when the VPN client writes
`/etc/resolv.conf` itself.
[ADR 0223](../../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md) gives
every machine one resolver file listing the mesh's two resolvers and nothing else, written by the holder
of the machine's uplink ([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). On the
laptop, a corporate VPN client replaces that file when it connects, with its own servers and eight search
domains. The node-engine finds the file changed and writes the mesh's back on its next reconcile. From
then on, for the rest of the session, the corporate names do not resolve; in the minutes before it, the
mesh's names did not. Neither program knows the other exists.
The questions:
1. **How the VPN client sets DNS** on Linux: does it overwrite the file, use `resolvconf`, or talk to
systemd-resolved or NetworkManager; what it backs up and restores; whether it re-asserts its file.
2. **What it pushes**: how many servers, which domains, over which link, and what routes.
3. **How often the two collide** on the live mesh, and what each collision costs.
4. **The options**: per-link routing in systemd-resolved, adding the VPN's servers to the mesh's file,
forwarding the corporate domains from the mesh's resolvers, standing back while the VPN is up, and
any other — and what each means for ADR 0223 and for the machines that run no VPN.
## Why
The operator uses the VPN for work, every working day. While it is up the laptop is either cut off from
the mesh (agents saw lookups fail) or cut off from the corporate network, depending on which program
wrote the file last. A rule of the mesh that a machine's own work program silently breaks — or that
breaks the machine's owner's work — is a rule that cannot be kept as stated.
A sibling decision proposed alongside this effort, ADR 0241 (machine network health), makes the
node-engine detect and report an outside writer of the resolver file. That says the fault; this effort
asks how it stops being one.
## What it touches
ADR 0223's rule that a machine lists only the mesh's resolvers, and its rejection of a local forwarder;
ADR 0117's list of what an uplink holder declares; the networkmanager module (the uplink holder on the
laptop); the node-engine's correction of a file changed on the machine; connectivity §2.
## Where it stands
Evidence gathered read-only on the laptop and options weighed, 2026-10-07:
- [01 — The evidence](01-evidence.md): the VPN client always *moves* the resolver file aside and writes
its own (two servers, eight search domains), restores its backup on disconnect, and never re-asserts;
it does not configure systemd-resolved or NetworkManager per link on this machine. In every one of the
nine sessions since the node-engine's journal begins, the node-engine wrote the mesh's file back within
1 s to 3.5 min; once the VPN client's restore of a stale backup was itself corrected.
- [02 — Options](02-options.md): six, from systemd-resolved per-link routing to replacing the VPN
client.
- [03 — Recommendation](03-recommendation.md): on a machine that declares a VPN client, the uplink's
holder runs systemd-resolved as a local router on the machine's private address, and hands it the
VPN's servers and domains the moment the VPN client writes them; every other machine is unchanged.
A proposed decision text with how each rule is checked.
**Graduated 2026-10-07** into [ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md)
and [to-be 50](../../03-DESIGN/01-to-be/50-split-dns-on-a-machine-with-a-vpn-client.md). The mechanism is
the recommendation's: systemd-resolved routing the VPN's domains over its link. Who runs it differs, by
the operator's decision. The resolver is a module of its own on a node seat, `node-resolver`, rather than
part of the uplink's holder, which has two forms. The VPN client's module carries the adapter, so no
setting declares a VPN client. The interim *defer* setting is not adopted. The record says why.
@@ -1,100 +0,0 @@
# 01 — The evidence
Gathered read-only on the laptop, 2026-10-07, with the VPN disconnected: the VPN client's installed
files, its own log (kept since its install, about five weeks), the strings of its binaries, the
node-engine's journal (kept for eleven days), and the machine's network state. Nothing was changed and
nothing restarted. The corporate network's domains and addresses are not reproduced here.
## The machine
- **Uplink:** NetworkManager, held by the mesh's `networkmanager` module, which tells it `dns=none` and
leaves the private network's interface (`mesh0`) unmanaged. The resolver file is the module's own,
declared from one template: the two mesh resolvers, `options timeout:1 attempts:2 edns0`.
- **systemd-resolved** is installed, as part of systemd, but disabled and not running. The name service
order is `files dns … resolve [!UNAVAIL=return]`, so with resolved stopped the resolver file is the
only DNS path.
- **The mesh's names are not in `/etc/hosts`** any more (ADR 0148 moved every name to the resolvers):
with the resolver file replaced, no mesh name resolves on the machine itself, nor in any container.
- **Five containers**: two on the node-engine network (they read the machine's file as it is, at each lookup),
three on bridges (they read it, or the runtime's embedded resolver copies its servers, when they start).
## The VPN client
**FortiClient 7.4, the vendor's own Linux client** (not openfortivpn), its scheduler running as a system
service; the tunnel is set up on demand by its `vpn` process.
**What it does at connect**, from its log, identical in all eleven sessions recorded:
1. `Inherit local DNS: No` and `DNS service resetting interval: 0` — settings pushed with the profile.
The first means the machine's own resolvers are *not* kept beside the corporate ones; the second that
it never re-asserts its file during a session.
2. It configures the tunnel device with `ip` ("fall back to using ip command") — so NetworkManager sees
the tunnel as an external device it does not manage.
3. **`Moving /etc/resolv.conf to /etc/resolv.conf.forticlient.backup`** — a rename, so the mesh's file,
inode and all, becomes the backup.
4. It writes a new `/etc/resolv.conf`: **two nameservers** (in a private RFC 1918 range, reached through
the tunnel) and **eight search domains** (the corporate network's internal and cloud zones). Its
binary carries the header it writes: `# Dynamic resolv.conf(5) file for glibc resolver(3) generated
by forticlient`, and a line saying the original is backed up and restored after the VPN disconnects.
5. **Split tunnel**: 119 routes through the tunnel — 110 host routes, mostly to a CDN's addresses, and
nine networks including the whole of `10.0.0.0/8` and `172.16.0.0/12`. The mesh's private /24 sits
inside the first, and survives only because a more specific route on `mesh0` wins.
**At disconnect:** `Moving /etc/resolv.conf.forticlient.backup to /etc/resolv.conf` — the backup taken
at connect is put back as it was then, whatever has happened to the file since. With no backup present
it logs "No DNS backup file was found. Skip."
**What it does not do on this machine.** Its binary knows systemd-resolved (if running, it *reads* the
resolvers from resolved's own file and flushes resolved's cache) and NetworkManager (it can allocate the
tunnel through `nmcli`, set a connection's DNS search domains, and drop a configuration file into
NetworkManager's `conf.d`, keeping its own backups beside the resolver file). Here it took neither path.
In no path found in the binary does it give systemd-resolved a per-link server or routing domain; it
always writes `/etc/resolv.conf` itself. Public reports agree: with systemd-resolved running, the client
replaces the stub symlink with its static file.
## The collision
The node-engine corrects a declared file that was "changed on the machine since this host last wrote it"
on its next apply or reconcile. Matching its journal to the VPN client's log:
| Session | VPN wrote its file | Node-engine wrote the mesh's back | Gap |
|---|---|---|---|
| 1 | 21:20:18 | 21:21:38 | 1 min 20 s |
| 2 | 11:51:33 | 11:54:48 | 3 min 15 s |
| 3 | 12:59:48 | 13:00:59 | 1 min 11 s |
| 4 | 12:26:30 | 12:29:55 | 3 min 25 s |
| 5 | 12:31:36 | 12:34:54 | 3 min 18 s |
| 6 | 12:42:58 | 12:44:54 | 1 min 56 s |
| 7 | 13:53:22 | 13:54:06 | 44 s |
| 8 | 14:58:37 | 15:00:35 | 1 min 58 s |
| 9 | 15:46:13 | 15:46:14 | 1 s |
- **Nine of nine sessions** since the journal begins were overwritten by the mesh, within 1 s to 3.5 min
(median about 1 min 56 s). Two earlier sessions predate the journal.
- **Each session then ran on the mesh's file** — sessions lasted from five minutes to fourteen hours —
so for all but the first minutes **no corporate name resolved**, while the tunnel and its routes stood.
The VPN client never noticed: its resetting interval is 0.
- **Sessions 4–6 were three connects within sixteen minutes**, two ended by hand after five and seven
minutes — the shape of a person reconnecting because something stopped working.
- **The VPN client's restore is itself an outside write.** On one night the uplink's holder changed (the
resolver file moved from one module's template to another's) while a session was up; when the session
ended at 02:43 the client put back its backup — the old module's file — and the node-engine corrected
it two minutes later. A restore puts back *a* mesh file, not necessarily the current one.
- **In the minutes before each correction**, the machine had only the corporate servers: no mesh name
resolved (the agents' lookups failed), and public names depended on the corporate servers. The
operator reported agents seeing `ENOTFOUND` for the AI API's public name in such a window; it is not in
any journal kept on the machine, so how often public names failed is not measured.
## What the other machines have
No VPN client runs on the anchor, the home server or the workstation. The two mesh resolvers have no
route to the corporate network's servers: they sit behind the laptop's tunnel, in ranges the laptop
routes only to itself.
## Two related faults, noted
- **One mesh resolver briefly slow under load** ([issue 277](../../04-ISSUES/277-one-unanswered-question-was-an-urgent-alert-nobody-could-read/00-report.md)):
any design that adds a hop must keep the two-resolver answer and its short timeouts.
- **The self-check asks the resolvers only from the control node** (to-be 45 §4, D2). A machine whose
own resolver file is wrong is invisible to it — the gap ADR 0241 (proposed) closes from the machine's
side.
@@ -1,113 +0,0 @@
# 02 — Options
What any answer must give, from the [evidence](01-evidence.md) and ADR 0223:
- **R1** mesh names resolve on the machine and in its containers while the VPN is up;
- **R2** the corporate domains resolve through the VPN's servers while it is up;
- **R3** public names resolve, VPN up or down;
- **R4** no listed set of servers can give two different answers to one name — musl asks every listed
server at once and takes the first reply, glibc takes the first server's "no such name" as final
([issue 262](../../04-ISSUES/262-an-alpine-container-could-not-find-a-machine-by-its-mesh-name/00-report.md),
ADR 0223);
- **R5** the VPN client keeps working unmodified — it is the employer's software, configured by the
employer's gateway; the mesh cannot change what it pushes or how it writes;
- **R6** no change on a machine that runs no VPN.
A constraint every option meets: **the VPN client always writes `/etc/resolv.conf` itself**, even with
systemd-resolved running. No option makes it stop; each decides what happens next.
## 1. systemd-resolved with per-link DNS and routing domains
resolved runs on the machine. The `mesh0` link gets the two mesh resolvers with the routing domain
`~internal` and the default route (`~.`); the tunnel link gets the VPN's two servers and its eight
domains as routing domains. resolved sends each question to the link whose domain matches longest, the
rest to the default route.
- **R1–R3 met**, by routing rather than by listing: one answer per name.
- **Containers cannot reach resolved's stub on `127.0.0.53`.** A container on the node-engine network can; one
on a bridge cannot — it reads the machine's file and gets an address that is its own loopback. resolved
can listen on further addresses (`DNSStubListenerExtra=`): on the machine's private mesh address, which
ADR 0223 already relies on being reachable from containers on a holder. The machine's file then lists
that one address. **R4 met**: one server listed.
- **Who gives the tunnel link its servers?** Not the VPN client (it never sets per-link DNS) and not
NetworkManager (the tunnel is not its device). Something must read what the VPN pushed — and the VPN
client writes exactly that, in its file, within a second of connecting. A path watch on the resolver
file, declared by the uplink's holder, can read the file's `nameserver` and `search` lines, give them to
the tunnel link with `resolvectl dns` and `resolvectl domain`, and write the mesh's file back at once —
not at the next reconcile. When the tunnel goes, resolved forgets its link's configuration by itself.
- **The VPN client's restore at disconnect** puts back the mesh's file (or an older one: the node-engine
corrects the content, as today). Nothing points at a link that is gone.
- **Cost:** a daemon on the machine (already installed; part of systemd), its configuration, the watch.
- **For ADR 0223:** it is the local forwarder 0223 rejected (its option 2), on fewer machines and with
its three objections met: not on every machine — only on one that declares a VPN client; reachable
from containers — on the private address; and it holds no copy of the mesh's names — every `.internal`
question still goes to the two resolvers, so nothing can disagree with the truth.
- **R6 met** if it is scoped to machines that declare a VPN client.
## 2. The uplink's holder adds the VPN's servers to the mesh's file
When the tunnel appears, the holder writes the mesh's two resolvers *and* the VPN's two servers, and the
VPN's search domains.
- **R4 broken, by construction.** The VPN's servers answer "no such name" for `.internal`; the mesh's
resolvers forward a corporate name to the public internet, which answers "no such name" or a public
record. glibc stops at the first listed server's answer, musl takes whichever is fastest. Whatever order
is chosen, one kind of name fails — the exact fault ADR 0223 removed the public resolver for.
- Rejected. It only works with something that routes by domain in front — which is option 1 or 5.
## 3. The mesh's resolvers forward the corporate domains while the VPN is up
- **They cannot reach the VPN's servers**: those sit behind the laptop's tunnel, routed only on the
laptop. Forwarding through the laptop would make the mesh's resolvers depend on one laptop's session.
- It would also put an employer's zones into the mesh's shared configuration, answered for every machine.
- Rejected.
## 4. Stand back: detect the VPN and defer
While the resolver file carries the VPN client's marker and its backup holds the mesh's file, the
node-engine does not correct it, and the machine's health says *deferred to a VPN client* rather than
*rewritten by another program*. On disconnect the client restores the mesh's file.
- **R2, R3 met; R1 broken for the whole session**: no mesh name resolves on the laptop, nor in its
containers. The node-engine reaches the bus by name; agents and tools on the laptop lose the mesh.
- **Cheap**: no daemon, one rule in the node-engine (and the content check on restore stays).
- Today's behaviour is the opposite trade — R1 met after a few minutes, R2 broken for the rest of the
session — and the evidence shows the operator living with it by reconnecting.
- Acceptable only as a stated, chosen degradation for a machine where the owner's work comes first;
better than the fight, worse than routing. **Useful as the fallback** of option 1 while it is not
built, if the operator prefers work names to mesh names.
## 5. A local forwarder of the mesh's own (dnsmasq) on the VPN machine
As option 1, with the catalogue's resolver program in place of resolved: `server=/<corporate domain>/…`
lines for the VPN's domains, everything else to the two mesh resolvers, listening on the private address.
- Meets R1–R4 like option 1.
- **Costs more than option 1**: a package to declare, and a configuration file to rewrite and a
process to reload on every connect and disconnect, where resolved takes per-link settings live over its
own interface and drops them with the link. dnsmasq is already the mesh's resolver module, which makes
"a second copy of the resolver on this machine" easy to misread as a third mesh resolver.
- Kept as the fallback for a machine without systemd.
## 6. Replace the VPN client
openfortivpn, through NetworkManager's plugin, hands the VPN's DNS to NetworkManager instead of writing
the file; NetworkManager with `dns=systemd-resolved` then gives resolved per-link servers and domains —
option 1 with no watch at all.
- **Not the mesh's to decide** (R5): the employer's gateway may require its own client (posture checks,
single sign-on through the vendor's flow). It would also hand the file back to NetworkManager, against
ADR 0117's `dns=none`.
- Recorded so the option is not rediscovered; not proposed.
## Summary
| | R1 mesh | R2 corporate | R3 public | R4 one answer | R6 others untouched | New on the machine |
|---|---|---|---|---|---|---|
| 1 resolved, per link | yes | yes | yes | yes | yes, if scoped | resolved, a path watch |
| 2 add the VPN's servers | some | some | yes | **no** | yes | nothing |
| 3 forward from the mesh | yes | **no** | yes | yes | **no** | nothing |
| 4 defer | **no** | yes | yes | yes | yes | one rule |
| 5 dnsmasq locally | yes | yes | yes | yes | yes, if scoped | a package, a reload per connect |
| 6 replace the client | yes | yes | yes | yes | yes | not the mesh's call |
| today (the fight) | after minutes | **no**, after minutes | mostly | yes | yes | — |
@@ -1,89 +0,0 @@
# 03 — Recommendation
From the [evidence](01-evidence.md) and the [options](02-options.md): **route by domain on the one
machine that runs a VPN client, and leave every other machine as ADR 0223 has it.** On a machine whose
operator declares a VPN client, the uplink's holder runs systemd-resolved as the machine's resolver,
listening on the machine's private address; the mesh's link carries the two mesh resolvers for the
mesh's domain and as the default route; the moment the VPN client writes its own resolver file, the
holder hands that file's servers and domains to the tunnel link and writes the mesh's file back. The
fight ends because the two programs no longer want different things: the VPN's servers are used, for the
VPN's domains only.
Why not the cheaper ones: adding the VPN's servers to the file breaks the one-answer rule that ADR 0223
exists for (option 2); the mesh's resolvers cannot reach the VPN's servers (option 3); standing back cuts
the laptop off from the mesh for whole working days (option 4). Option 4 is kept as what the machine does
*until* this is built, if the operator prefers it to today's fight — a choice for the operator, stated in
the proposed text as an interim setting, not as the decision.
What it asks of the sibling proposal, ADR 0241 (machine network health): on a machine that declares a VPN
client, the VPN client's file is not an outside writer's fault but an expected handover — it is reported
as *adopted* once the holder has taken its servers, and as a fault only if it stands longer than the
watch's bound (a few seconds), which means the watch did not fire.
## Proposed decision text
Ready for graduation through playbook 02. The number is assigned then.
> **A machine with a VPN client routes names by domain, and the VPN's file is taken, not fought**
>
> **Context.** ADR 0223 gives every machine one resolver file listing the mesh's two resolvers and
> nothing else, written by the uplink's holder (ADR 0117). A corporate VPN client on the laptop moves that
> file aside when it connects and writes its own — two servers reached through its tunnel, eight search
> domains — and puts its backup back on disconnect; it never re-asserts its file and never gives
> systemd-resolved per-link settings, even when resolved runs. The node-engine wrote the mesh's file back
> in nine of nine sessions, within 1 s to 3.5 min; from then on no corporate name resolved for the rest of
> sessions lasting up to fourteen hours, and before it no mesh name did. No resolver file can list both
> sets of servers: musl takes the first reply and glibc the first server's "no such name", so one kind of
> name would fail (issue 262).
>
> **Decision.**
>
> 1. **A machine declares that it runs a VPN client**, as a setting of its uplink's holder, naming the
> client. Nothing changes on a machine that does not.
> 2. **On such a machine the uplink's holder runs systemd-resolved** as the machine's only resolver, with
> its stub also listening on the machine's private address, so the machine's containers reach it. The
> private network's link carries the mesh's resolvers, in ADR 0223's order, as the routing domain of the
> mesh's names and as the default route. `/etc/resolv.conf` lists the machine's private address alone,
> with ADR 0223's options. resolved holds no copy of any mesh name: every mesh name is asked of the two
> resolvers, as on every other machine.
> 3. **The VPN client's file is taken, not fought.** The holder watches the resolver file; when the
> declared client writes it, the holder gives the tunnel link the file's servers and its search domains
> as routing domains, and writes the mesh's file back, within seconds — not at the next reconcile. When
> the tunnel goes, resolved drops its link's settings; the client's restore of its backup is corrected
> by content, as any change to the file is.
> 4. **The VPN's domains are never the mesh's.** They are not written to the mesh's store, its resolvers
> or any other machine; they live in resolved's state for the life of the tunnel.
> 5. **The machine says it.** The machine's network health (ADR 0241, proposed) states the VPN's file as
> *adopted*, with the tunnel link and the count of domains routed, and raises it as an outside writer
> only if it stands longer than the watch's bound or the client is not the declared one.
> 6. **Until rules 2–3 run on a machine, its operator may choose to defer**: the node-engine leaves the
> declared client's file in place while the client's backup holds the mesh's file, and the machine's
> health says *deferred to the VPN client — mesh names do not resolve* for as long as it lasts.
>
> **Consequences.** ADR 0223's rejection of a local forwarder stands for every machine but one that
> declares a VPN client; there its three objections are answered — one machine, not every machine;
> reachable from containers on the private address; no copy of the mesh's names. A machine that
> declares a VPN client and whose resolved is down has no names; its network health says so. The
> routes the VPN client adds are untouched: its routes include `10.0.0.0/8`, and the
> mesh's private network keeps working only because its own route is more specific — a fact to keep in
> mind when choosing a private range, and a check of its own.
>
> **How it is checked.**
>
> | Rule | Checked by |
> |---|---|
> | 1 Declared, nothing changes elsewhere | a controller composition test: a machine without the setting composes exactly as before; with it, the holder declares resolved and the watch |
> | 2 resolved, on the private address, mesh link and default route | a composition test reading the holder's files; live on the laptop: `resolvectl status` shows the mesh link with `~internal` and `~.`, the stub on the private address, and the machine's file lists that address alone |
> | 3 Taken within seconds | a lab drill: a container plays the VPN client (writes a file with its own header, servers and search lines, adds a tunnel link); the tunnel link carries the servers and domains and the mesh's file is back within 5 s; removing the link drops them |
> | 3 Containers | the lab drill: an Alpine container on a bridge resolves a mesh name ten times out of ten while the fake tunnel is up |
> | 4 Never the mesh's | a test that the store, the controller's renders and the other machines' files carry none of the VPN's domains after the drill |
> | 5 Said | the machine network health test: the VPN's file read as *adopted*, not raised; raised when it stands past the bound |
> | 6 Defer, while not built | a node-engine test: with the setting and the client's backup present, the file is not corrected and the health says deferred; without the backup it is corrected |
> | the routes | the machine network health's check that a mesh address still routes over the private link (ADR 0241, proposed) |
## What this does not decide
- Which VPN client the operator uses (option 6): the employer's.
- Resolution for a machine with *two* VPN clients — none exists.
- Whether the operator's corporate names should resolve inside the mesh's containers on the laptop: they
will, through the same router; restricting them is a later question if it matters.
@@ -1,111 +0,0 @@
---
status: graduated
initiated: 2026-10-07
touches:
- 00-META/glossary.md
- 00-META/checks/
- 02-DECISIONS/0006-the-substrate-and-the-control-plane.md
- 02-DECISIONS/0008-a-context-owns-its-store.md
- 03-DESIGN/01-to-be/06-the-controller.md
- the descriptions of every module's tools and every seat's verbs in the catalogue
became:
- 02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md
- 03-DESIGN/01-to-be/49-the-mesh-in-domains.md
---
# 034 — The mesh in domains
## What is investigated
The operator, 2026-10-07: *"I wanted to develop our nox-mesh domain driven. Meaning every concept
should fit into some domain and we try to come up with a common knowledge base/glossary/jargon for our
application. We kind-of do this already I think, yet sometimes, you return different words for existing
concepts."*
Two terms from domain-driven design are used throughout this effort, so they are explained once here.
A **domain** (in the literature, a *bounded context*) is an area of the system inside which every word
has exactly one meaning, and which owns the concepts that meaning describes. A **ubiquitous language** is
the set of words a domain uses the same way in conversation, in documents and in code. The glossary
([`00-META/glossary.md`](../../00-META/glossary.md)) is this repository's attempt at a ubiquitous
language for the whole mesh, without domains.
The effort asks five questions:
1. **Which concepts are in use**, in the glossary, the decision records, the designs and the issue
reports, and in the words the running mesh's tools use to describe themselves?
2. **Into which domains do they fall?** Each domain gets a purpose, the concepts it owns, the one word
for each, and its relation to the others. The operator's starting guess — building and delivering
changes; running modules on machines; health and alerts; data and backups; people, conversation and
approvals; the network — is tested, not assumed.
3. **Where do the words clash?** Two or more words for one concept (a *synonym*), or one word for two
concepts (a *homonym*), each with evidence and a proposed single word.
4. **How is the glossary rule checked?** [`AGENTS.md`](../../AGENTS.md) says *a rule states how it is
checked*, and "one name per thing" is checked by nothing today.
5. **How should the glossary be organised by domain?** Described only; the glossary itself changes when
this effort graduates, not before.
## Why
Because the drift is measurable, and it costs. The glossary retired *control plane* on 2026-09-16; three
weeks later 29 occurrences stand in 12 to-be designs — the documents that tell somebody what to do — and
73 files created after the retirement use it. It retired *host agent*, *the host* and `mesh-host` for the
**node-engine** on 2026-10-05; the descriptions of four tools the running mesh serves still say *"the host
will restore"*, and the glossary's own entry for node tools says *"a host-side process the host
supervises"*. The glossary defines **node**, while the decisions and designs of the last week use
*machine* twice as often as *node*, and the console's own tools are called `mesh_machine` and list
"machines". A reader — person or agent — who meets two words assumes two things, and an agent answering
from these documents repeats whichever word it read last. That is the operator's complaint, observed.
## What it touches
The glossary and how it is kept; the checks in [`00-META/checks/`](../../00-META/checks/); the seven
contexts of the controller that [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md)
named and [ADR 0008](../../02-DECISIONS/0008-a-context-owns-its-store.md) gave each its own store, which
are the nearest thing the mesh already has to domains; and the descriptions of the tools and seat verbs
that the modules in the catalogue serve, which are where an agent meets the mesh's words most often.
## The documents
| | |
|---|---|
| [01 — The concepts in use](01-the-concepts-in-use.md) | the inventory: what was read, how it was counted, what the glossary holds and what it lacks, and the words the running tools use |
| [02 — The domains](02-the-domains.md) | the proposed split into ten domains plus the repository's own, each with purpose, concepts, words and relations; the operator's guess tested |
| [03 — The clashes](03-the-clashes.md) | twenty clashes with evidence and a proposed word for each, the five most costly first |
| [04 — How the glossary is checked](04-how-the-glossary-is-checked.md) | a proposed check over hq documents and over the catalogue's tool descriptions; described, not built |
| [05 — A glossary by domain](05-a-glossary-by-domain.md) | how the glossary would be organised; described, not written |
## Findings so far
- **The mesh already has domains under another name.** ADR 0006 named seven *contexts* of the controller —
inventory, config, connectivity, provisioning, delivery, observability, identity. Only three of them have
a store today (`inventory`, `identity`, and `licences`, which is not one of the seven), and the words the
mesh has grown since (seat, delivery, condition, ask, data class) are not organised by them. The
proposed split in [02](02-the-domains.md) keeps five of the seven, renames one, and adds four.
- **The operator's guess survives mostly intact.** Five of its six domains hold up. It lacks a domain for
*identity and access* (credentials, grants, licences), one for *provisioning* (provider and consumer),
the *core* the rest runs on (controller, bus, store, lease, genesis), and the *module* itself, whose
manifest is the language every other domain reads. *Health and alerts* is better called *health and
repair*: the mesh says *condition*, not *alert*.
- **The glossary is short of the vocabulary in use, and contradicts itself twice.** It has 33 entries. At
least 40 words used in more than ten decision records each — *module*, *manifest*, *assignment*,
*machine*, *provider*, *condition*, *gate*, *tier*, *operator* among them — have no entry. It defines the
console as a module and, further down, says node tools replaced that name; it says the deprecated broker
holds no seat and, in the entry for *claim*, that it claims `mesh-broker`.
- **Most drift is in two places**: governing designs written before a word was retired and never
revisited, and tool descriptions, which nothing compares with the glossary at all.
- **Homonyms cost more than synonyms**, and a word list cannot catch them. *Plan*, *push*, *release*,
*gate*, *tier*, *ask*, *store* and *record* each mean two to four things today, several of them in the
verbs of the same seat.
## Questions for graduation
- **Machine or node.** One concept, two words, both heavily used; [03](03-the-clashes.md) recommends
*machine* in prose and keeps `node` only as an identifier (a scope's value and the `node-` prefix of seat
names). This is the largest rename the effort proposes, and the operator's call.
- **Domain or context.** The repository already says *context* (ADR 0006, ADR 0008) for an owner of
records with its own store. Using *domain* for the vocabulary partition beside it would be the effort's
own synonym. [02](02-the-domains.md) proposes that a domain is the vocabulary and ownership boundary and
a context is one store inside it, and asks for that to be decided rather than left to usage.
- **Which documents the check holds to the list**, and from which date ([04](04-how-the-glossary-is-checked.md)).
- **Where the tool-description check runs**: in the catalogue repository's merge check, or as a probe of the
running mesh, or both.
@@ -1,148 +0,0 @@
# 01 — The concepts in use
What was read, how words were counted, what the glossary holds, what it lacks, and which words the
running mesh's tools use. The domains in [02](02-the-domains.md) and the clashes in
[03](03-the-clashes.md) are drawn from this.
## What was read
| Source | Size on 2026-10-07 | How |
|---|---|---|
| the glossary | 33 entries | read whole |
| decision records (`02-DECISIONS/`) | 225 records | counted; the seven-contexts record (0006), its store rule (0008) and the records from 0220 to 0240 read for their words |
| to-be designs (`03-DESIGN/01-to-be/`) | 49 documents | counted; the controller (06), connectivity (08), and 45 to 48 read for their words |
| as-is designs (`03-DESIGN/00-as-is/`) | 17 documents | counted |
| issue reports (`04-ISSUES/`) | 291 issues | counted |
| research (`01-RESEARCH/`) | 33 efforts | counted; research 005 (*which domains the catalogue groups into*) read whole, as prior art |
| the running mesh's tools | 88 modules serving 535 tools, plus the verbs of 15 seats every machine holds and 5 seats held once for the mesh | listed through the console's read tools (overview, machine, search, describe); nothing was called that changes anything |
**How words were counted.** A case-insensitive, whole-word match per file; the number given is the number
of *files* containing the word, not of occurrences, unless a statement says otherwise. Where a file's age
matters, its age is the date git first records it. The tool descriptions are the first line each tool
gives of itself in the console's listing, which is cut at about 160 characters, so a word that occurs only
late in a long description is undercounted there.
## What the glossary holds
Thirty-three entries, in seven sections: the mesh and its machines; what runs the mesh; what the mesh
stores and serves; how modules relate to the mesh; the surfaces; how a change reaches the machines; the
operator's machine. Retired words are marked three ways — struck through (`~~flavor~~`), *"Replaces
**x**"*, and *"Not x"* — so no program can read the list of retired words from it reliably today.
**It contradicts itself in two places.**
- **The console.** The surfaces section defines the console as *"the module (`mesh-console`) that puts the
mesh's tools in front of whoever is on a machine"*. The operator's machine section, further down, defines
node tools as the runtime whose *"serving mode on loopback is what was called **the console**"*, and
says node tools *"replaces 'console' as the module's name; console remains the word for the person's end
of it."* Both entries are current.
- **The deprecated broker.** Its entry says it has *"no seat, not foundation"*. The entry for *claim*
says *"the `mesh-controller`, `postgres` and `lavinmq` modules claim the `mesh-controller`, `mesh-store`
and `mesh-broker` seats"*, and the entry for *bus* says the bus is held by the `mesh-broker` seat.
**It uses a word it retired.** The node tools entry says *"a host-side process the host supervises"*,
eight lines after the node-engine entry retired *the host*.
## What the glossary lacks
Words used in more than ten decision records each, with no entry of their own (files containing each, of
225 records):
| Word | Records | Word | Records | Word | Records |
|---|---|---|---|---|---|
| machine | 177 | operator | 125 | catalogue | 120 |
| declaration | 106 | manifest | 102 | reach | 101 |
| provider | 95 | person | 91 | adopted | 84 |
| tool | 76 | holder | 75 | grant | 65 |
| assignment | 60 | secret | 56 | scope | 55 |
| apply | 54 | resolver | 51 | genesis | 46 |
| verb | 45 | firewall | 43 | route | 42 |
| converged | 40 | private network | 37 | connectivity | 34 |
| the lab | 33 | proxy | 31 | overlay | 30 |
| upgrade | 27 | gate | 25 | condition | 23 |
| endpoint | 23 | reconcile | 22 | vault | 20 |
| hub | 19 | finding | 19 | health | 19 |
| licence | 16 | probe | 15 | tier | 14 |
| membership | 14 | anchor | 14 | backup | 11 |
*Module* itself — the word the whole mesh is built on — has no entry. Nor do the words of the four
designs written in the last week: *condition*, *healer*, *self-check*, *watchdog*, *hand-act*, *drill*,
*lease*, *epoch* (to-be 45, *a core that cannot fail silently*); *data class*, *restore point* (to-be 43
and ADR 0233); *converged* and *adopted* as the two states a machine is in (the controller's `nodes` verb
answers with them).
## The seven contexts, as they stand
ADR 0006 split the controller into seven **contexts** — areas of record, each owning its own store
(ADR 0008) — and to-be 06 tabulates them:
| Context | Owns, in ADR 0006's words | A store of its own today? |
|---|---|---|
| inventory | nodes, modules, assignments, versions | yes |
| config | settings, secrets, and deriving them onto nodes | no |
| connectivity | overlay, resolution, exposure, filtering, certificates | no |
| provisioning | resource grants between modules | no |
| delivery | source to artifact to node | no — deliveries are kept by the `mesh-delivery` module since ADR 0239 |
| observability | health, logs, metrics, alerts | no |
| identity | agents, humans, services, authorisation | yes |
The store has a third database the seven do not name, `licences` (ADR 0183). The seven are the nearest
thing the mesh has to domains, and they were drawn from one question — *does answering this need more than
one node?* — which is a test for what the controller owns, not for where a word belongs. Seat, claim,
bench, delivery group, condition, ask, data class and every word of the operator's conversation arrived
after them, and none of those records says which context its words belong to.
Research 005 asked a neighbouring question in August — which *modules* group into domains — measured which
modules change together, and found that only the reachability modules do. Its conclusion, recorded in ADR
0009, was that modules are not grouped into packages; it did not touch the vocabulary. This effort groups
*concepts and words*, not modules: a module may serve several domains, and the domains are not folders in
the catalogue.
## The words the running tools use
The console's listing gives each tool's first line. Over the 535 module tools (the seat verbs are counted
separately below):
| Word | Tools | Modules | Note |
|---|---|---|---|
| machine | 39 | 25 | the word most modules use for where they run |
| node | 27 | 5 | 21 of the 27 in the agent's configuration module; the others the vault, the bus server, the packet filter and a flow editor whose own objects are called nodes |
| the host / host's | 6 | 2 | the container module's `start` and `stop` tools, meaning the node-engine; the remaining uses are a network's hosts |
| operator | 28 | 23 | the person; consistent with the glossary |
| user | 30 | 12 | almost all a wrapped program's own accounts (an identity provider's users, the forge's users, a database's users) |
| install | 43 | 16 | a package installed on a machine, never the mesh's assignment |
| condition | 0 | 0 | — while the controller's verb is `conditions` |
| seat | 0 | 0 | the module tools never name a seat; only the console and the controller do |
| deploy, rollout, pipeline, control plane | 1, 0, 0, 0 | — | the one *deploy* is a wrapped program's own (a flow editor's) |
The seat verbs say:
- `<machine>/node-service-manager.start` and `.stop`: *"the answer says **the host** will restore what its
declaration says at its next apply"*.
- the bus server module's `connections` tool: *"a machine's **host** `node.<machine>`, a machine's runtime
`<machine>.node-tools`"* — three words (host, node, runtime) for two programs, in one line.
- `mesh-controller.plan`: *"What one machine would run, and why: **the declaration** the mesh would send
it"*; `mesh-controller.plans`: *"What the last merges produced … the tiers"*;
`mesh-controller.delivery-plan`: *"The delivery plan of a diffset"*. Three meanings of *plan* on one
seat.
- `mesh-controller.queue`, `.cancel`, `.clear`: *"Every **ask** in the build queue"* — while the glossary's
*ask* is a request for the operator's input.
- `mesh-delivery.release`: *"A person's word that a held delivery goes on"*; and
`anthropic-licence-manager.release`: *"Unbind a consumer"*.
- `mesh-controller.doctor`: *"The **self-check** …"* — the verb and the concept named differently.
- the nftables module's tool is called `firewall_rules`, and the seat it holds on every machine is
`node-packet-filter`, whose verb is `rules`.
- `mesh-controller.nodes`: *"Every **machine** the mesh knows"*; the verb is named for nodes and answers
in machines.
- `node-build-agent` is the name of a seat every machine holds, while the glossary avoids *agent*
*"because the word already means two other things here, the build agent and the operator's coding
agent"*, and ADR 0237 calls the same role *the build seat*.
- `operator-channel` is a seat of the mesh, held by the conversation's router (ADR 0234); the glossary
names the router and the `channel` bench but not the seat.
**Vendor words are not drift.** A module that wraps a program speaks that program's language where it
describes the program's own objects: an identity provider has *users* and *realms*, a media manager has
*releases*, a flow editor *deploys*. That is correct and must stay so; a check that flagged it would be
wrong ([04](04-how-the-glossary-is-checked.md) scopes the list to the mesh's own words for that reason).
The drift is where a tool describes **the mesh's** objects — its machines, its engine, its seats — in a
word the glossary retired or never had.
@@ -1,292 +0,0 @@
# 02 — The domains
A proposed split of the mesh's concepts into domains, tested against the operator's starting guess and
against the seven contexts of ADR 0006.
## What a domain is here, and how one was drawn
A **domain** is an area of the mesh that **owns** a set of concepts: inside it each concept has one
word, and the domain decides what that word means. Another domain may use the word — that is what makes
it a shared language — but may not change its meaning. Two relations between domains are named:
- **upstream / downstream.** Domain A is *upstream* of B when B depends on A's concepts and A does not
depend on B's. A change of meaning upstream ripples down; never the other way.
- **shared word.** A word two domains both use. It is safe when both mean the same thing and one of
them owns it; it is a clash when they mean different things ([03](03-the-clashes.md)).
Three tests decided where a concept belongs, in this order:
1. **Who records it.** The context, module or seat that writes the record of it owns it (ADR 0008: *a
context owns its store*). A condition is the controller's record; a delivery is `mesh-delivery`'s.
2. **Whose decision records define it.** The records that introduced the word, and the to-be design that
names those records in its frontmatter.
3. **Which other words it is never used without.** *Walk* never appears without *delivery*, *tier* and
*gate*; *healer* never without *condition* and *budget*.
**Domain and context.** ADR 0006's *context* is narrower than a domain: it is one owner of records, with
one store, inside the controller. This effort proposes that a domain may hold zero, one or several
contexts, and that the two words are kept apart: **domain** for the area of vocabulary and ownership,
**context** for a store-owning part of the controller. Whether to adopt *domain* at all, or to widen
*context* to mean it, is a question for graduation ([00](00-overview.md)); this document uses *domain*.
## The split
Ten domains of the mesh, and one of this repository. They are ordered from the most upstream to the most
downstream; each lists its purpose, the concepts it owns with the proposed word for each, and its
relations.
### 1. Module — what a module is and declares
**Purpose.** To say what one module is, in the one document every other domain reads: its manifest.
**Owns.** *module*; *manifest* (the file a module is defined by — not *module definition*, not
*module.yml* in prose); *resource* (what a manifest declares a machine must have: a file, a service, a
container, a package); *declares* (the verb for what a manifest states); *provides* / *requires* (as
written in a manifest; their meaning is Provisioning's); *claim* (a manifest's request to hold a seat);
*setting* (a value a manifest declares and an assignment gives); *kept region*; *bundle* and *tools
bundle*; *tool* (what a module serves) and *verb* (what a seat carries); *invokes*; *upgrade policy*
(`roll`, `together`, `record`); the declarations of *data* and *health* (their meaning is Data's and
Health's).
**Relations.** The most upstream domain. Every other domain reads the manifest, which makes its words a
**published language** — a vocabulary fixed in a schema that others conform to. It depends on nothing
but the Core's notion of a scope.
**Against the seven contexts.** None; the seven describe the controller, and the manifest is not the
controller's.
### 2. Core — the mesh's own machinery
**Purpose.** To keep the controller, the bus, the store and every machine's engine running and agreed,
so that every other domain has something to run on.
**Owns.** *controller*; *control-node*; *node-engine*; *node tools* (the tool runtime on every machine);
*foundation*; *store* (the one database server); *bus*; *broker seat*; *genesis* (raising the foundation
on an empty mesh); *lease* and *epoch* (ADR 0229: which controller may send, and the order of what it
sent); *context* (a part of the controller that owns a store).
**Relations.** Upstream of every domain but Module. Every other domain is a **conformist** to it — it
uses the bus and the store as they are and has no say in their shape.
**Against the seven contexts.** Not one of them: ADR 0006 described what the controller owns, and the
Core is the controller itself.
### 3. Placement — what runs on which machine
**Purpose.** To decide, send and apply what each machine runs, and to say why.
**Owns.** *machine* (or *node* — see clash 1); *operator account*; *assignment* (a module put on a
machine); *seat*, *scope* (machine, site, mesh), *capacity*, *bench*, *kind*, *holder*, *holding*;
*depends on a seat*; *declaration* (what the controller sends one machine — see clash 9); *send* (the
controller giving a machine its declaration; `push` is the verb that asks for it — clash 4); *apply* (the
node-engine making a machine match its declaration); *reconcile*; *converged* and *adopted* (a machine's
two states, ADR 0100); *pin* is Provisioning's, not this domain's.
**Relations.** Downstream of Module and Core. Upstream of Provisioning, Connectivity, Data and Health,
which all need to know what runs where. Shares *setting* with Module (a manifest declares it, an
assignment gives it its value) and *declaration* with Module (clash 9).
**Against the seven contexts.** ADR 0006's *inventory* (nodes, modules, assignments, versions) and the
derivation half of *config*.
The operator's guess called this *running modules on machines (seats, benches, claims)*, and it holds.
*Claim* belongs to Module (a manifest claims) and *holding* to Placement (an assignment holds), which is
the distinction the glossary already draws in *installed / holding*.
### 4. Provisioning — one module serving another
**Purpose.** To resolve which module serves a provision for which consumer, and to wire the two.
**Owns.** *provision*; *provider*; *consumer* (the module that requires); *pin* (a person's choice of
provider); *endpoint* (where a consumer reaches its provider); *pair credential* (the two ends of one
credential; the credential itself is Identity's); *retire* (a provider stops serving a consumer the mesh
no longer asks for, ADR 0230).
**Relations.** Downstream of Module (requires/provides), Placement (what runs where) and Identity (the
credential). Upstream of Data, which watches what a provider keeps for a consumer. Shares *consumer* with
Identity's licences (a licence is bound to a consumer) — the same idea, safely shared.
**Against the seven contexts.** ADR 0006's *provisioning*, unchanged.
The operator's guess has no such domain; its words are spread across *running modules* and *data*. They
are a language of their own: 95 decision records say *provider*, and four seat verbs (`pin`, `unpin`,
`retire`, `cleanup`) are about nothing else.
### 5. Identity and access — who may do what
**Purpose.** To issue, hold, rotate and check the credentials and grants by which modules, machines,
agents and the operator act.
**Owns.** *credential* (anything that proves an identity); *secret* (a credential or other value the vault
keeps sealed); *vault*; *grant* (what an identity may call or reach); *bus account* and *membership*; *rotate*;
*licence*, *binding* (a consumer bound to a licence), *adopt* (taking in a licence that existed before);
*proof* (what an authorising answer carries: a verified sender, a one-time code, a key's touch);
*operator identity* (the list the router checks a sender against).
**Relations.** Downstream of Core (the bus's accounts) and Placement (what is assigned where). Upstream of
Provisioning, Operator and conversation, and Change and delivery (whose builds are signed and whose
merges are approved).
**Against the seven contexts.** ADR 0006's *identity*, plus the secrets half of *config*, plus the
`licences` store, which is a context the seven did not name.
The operator's guess has no such domain. It is not small: *credential* is in 78 decision records, *grant*
in 65, *secret* in 56, and two of the three context stores that exist today (`identity`, `licences`) are
its.
### 6. Change and delivery — a commit on its way to the machines
**Purpose.** To take one commit from its pull request to every machine that should run it, and to put it
back when it fails.
**Owns.** *pull request*, *commit*, *trunk*; *merge check* (the two statuses a pull request carries, the
*merge gate* and the *repository check*, ADR 0238); *build*, *build seat*, *build queue* and the entry in
it (clash 7); *package* and *package-registry*; *artifact* and *artifact store*; *catalogue* (what the mesh
holds of every module, at which commit); *delivery*, *delivery group*, *delivery plan* (its *build plan*
and *deploy plan*); *walk*; *tier* in the sense of a step of a walk (clash 8); *first machine* and its
*gate* (clash 6); *rollback*; *the lab*, *scenario*, *replay*.
**Relations.** Downstream of Module, Core, Health (the gate reads health) and Data (a module holding
irreplaceable data is recorded, not rolled out, ADR 0236). Upstream of Placement in time — a walk asks
the controller to send — and of Operator and conversation, which carries its held deliveries to a person.
**Against the seven contexts.** ADR 0006's *delivery*, now owned by the `mesh-delivery` module rather than
by a context of the controller (ADR 0239).
The operator's guess called this *building and delivering changes*; it holds, with *change* kept for a
diff (the glossary already says so) and *delivery* for the thing that travels.
### 7. Health and repair — what is wrong, and putting it right
**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.
**Owns.** *health* (a module's or core component's statement of being well, ADR 0240); *probe*;
*signal* and *watchdog*; *self-check* (clash 13); *condition* (one open fact about something wrong, with a
key and a severity); *healer*, its *repair* and *budget*; *drill*; *hand-act*; *observation* (ADR 0231:
only observation raises or clears a condition).
**Relations.** Downstream of everything it watches. Upstream of Change and delivery (the gate) and of
Operator and conversation (a condition becomes a message).
**Against the seven contexts.** ADR 0006's *observability* — whose row says *"health, logs, metrics,
alerts"*. The mesh never built *alerts*; it built conditions, and the word *alert* survives only in a
wrapped dashboard program's tool. The domain is renamed accordingly: *observability* names the watching,
and half of what this domain owns is repair.
The operator's guess called this *health and alerts*. It holds as a domain, under the word the mesh
uses: *condition*, not *alert* (clash 14).
### 8. Data — what the mesh keeps, and getting it back
**Purpose.** To know every item of data a module holds, how precious it is, and to keep it recoverable.
**Owns.** *data* as a module declares it (ADR 0233); *data class* — *irreplaceable*, *rebuildable*,
*cache*; *backup*; *restore point*; *restore* (beside the live data, never over it); the bus's *stream
snapshot* (ADR 0235); *binding* of a consumer to its data moving only by a person (ADR 0232).
**Relations.** Downstream of Module (the declaration), Placement (where the data is) and Provisioning
(data a provider keeps for a consumer). Upstream of Change and delivery (irreplaceable data holds a
module back from rolling out) and of Health (data is watched).
**Against the seven contexts.** None. Data was not foreseen in ADR 0006; the self-check measures it today
(`mesh-controller.data`), and every machine's `node-backup` seat keeps it.
The operator's guess called this *data and backups*, and it holds.
### 9. Connectivity — how machines and modules reach one another
**Purpose.** To make every machine and module reachable by name where it should be, and unreachable
where it should not.
**Owns.** *private network* (clash 15); *resolver*; *uplink*; *hostname*; *zone*; *proxy* and *public
name*; *packet filter* (clash 16); *intrusion prevention* and *ban*; *reach* (how far an
assignment's endpoint may be reached from, ADR 0138); the roles a machine plays for the network (the anchor, a hub).
**Relations.** Downstream of Placement (what runs where) and Identity (who may reach). Upstream of
Provisioning (an endpoint must be reachable) and of Health (an unreachable machine is a condition).
**Against the seven contexts.** ADR 0006's *connectivity*, unchanged — and the one domain research 005
found real in the catalogue's history, where its modules are the only ones that change together.
The operator's guess called this *the network*, and it holds.
### 10. Operator and conversation — the person the mesh works for
**Purpose.** To let the mesh and its operator talk: tell, ask, answer, and act only on an answer it can
trust.
**Owns.** *operator* (the person who runs the mesh; clash 17); *person* (any human, as against the mesh
acting unattended); *console* (the person's or agent's end of node tools, clash 5); *channel* and *intake*
benches; *router* and the `operator-channel` seat it holds; *responder*; *ask*, *authorising ask*,
*answer*; *operator message* and *untrusted input*; *reference*; *notification* on a desktop.
**Relations.** Downstream of Health (conditions become messages), Change and delivery (a held delivery
waits for a person's word) and Identity (proofs, the operator's identities). An authorising answer acts in
whichever domain asked, through that domain's own verb; the conversation owns the asking, never the act.
**Against the seven contexts.** None. ADR 0006 named `api`, *"the one interface every surface speaks
to"*, as an interface rather than a context; the console and the conversation are what it became.
The operator's guess called this *people, conversation and approvals*. It holds, with one adjustment:
an **approval** is not this domain's concept. The approval of a merge is Change and delivery's (a human
approves the pull request); the release of a held delivery is `mesh-delivery`'s verb; the conversation
carries both as asks. *Approval* is in five decision records; *ask* in 58.
### 11. The record — how this repository works
Not a domain of the mesh but of the company's way of building it, and listed because its words collide
with the mesh's. **Owns** *research effort*, *decision record* (and *supersede*, *progressive insight*),
*design* (*as-is*, *to-be*), *issue*, *playbook*, *check* (one of `00-META/checks/`), *graduation*,
*hand-off*. Its *record* and *check* collide with the mesh's (clashes 11 and 12).
## The operator's guess, scored
| Guess | Verdict |
|---|---|
| building and delivering changes | **holds** as *Change and delivery* |
| running modules on machines (seats, benches, claims) | **holds** as *Placement*, with *claim* moved to Module |
| health and alerts | **holds, renamed** *Health and repair*: the mesh's word is condition, and half the domain is repair |
| data and backups | **holds** as *Data* |
| people, conversation and approvals | **holds** as *Operator and conversation*, with *approval* owned by the domain whose act is approved |
| the network | **holds** as *Connectivity* (ADR 0006's word) |
| — | **added**: *Module* (the published language), *Core* (what the rest runs on), *Provisioning* (ADR 0006 had it), *Identity and access* (ADR 0006 had it) |
## A map
```
Module (published language)
|
Core
|
+--------------------------+---------------------------+
| | |
Identity -------------> Placement |
| / | \ |
| / | \ |
v v v v |
Provisioning <------- Connectivity Data |
| | |
+-------------> Health and repair <------------------+
| |
v v
Change and delivery Operator and conversation
| ^
+-------------------+
(a held delivery waits for a person's word)
```
Arrows point downstream. Change and delivery appears low because it reads health and data; in time it
runs first, and asks Placement to send.
## What this split does not decide
- **Module ownership.** A module may serve several domains (the controller serves five). The domains
are a partition of words and records, not of the catalogue — research 005 already found that grouping
modules into folders records nothing.
- **Whether every domain gets a context with its own store.** ADR 0008's rule applies where a domain's
records live in the controller; *Change and delivery* has moved its records to a module instead, and
that is equally sound.
- **The operator's machine.** The desktop seats (display session, launcher, clipboard, notifier) are
Placement's seats like any other, and their words are each program's own. Research 018 and 026 did not
introduce a vocabulary that needs a domain.
@@ -1,306 +0,0 @@
# 03 — The clashes
Twenty places where the mesh's words disagree with each other. A **synonym clash** is two or more words
for one concept; a **homonym clash** is one word for two or more concepts. Each entry gives the evidence
— which documents and which tool descriptions say what — and a proposed single word, with the domain
([02](02-the-domains.md)) that would own it. Counts are files containing the word, as in
[01](01-the-concepts-in-use.md), unless they say *occurrences*.
The first five are ordered by cost: how often the clash appears where somebody is told what to do (a
to-be design, `00-META`, a tool description), and how likely it is to make a reader act on the wrong
thing. The other fifteen follow by domain.
A decision record keeps the words it was written with (its immutability rule), so a retired word found in
a decision record is not a fault and is not counted as drift below unless the record was written after
the word was retired.
## The five that cost most
### 1. *machine* and *node* — one concept, two words (synonym)
**Evidence.**
- The glossary defines **node**: *"a machine in the mesh"*, and has no entry for *machine*.
- In the decisions and designs of the last week (ADRs 0220 to 0240, to-be 45 to 48), *machine* occurs 706
times and *node* 349 times. Before ADR 0150, 6 record titles say *machine* and 9 say *node*; from ADR 0150
on, 17 and 17.
- In the tool descriptions, *machine* is in 39 tools of 25 modules; *node* in 27 tools of 5 modules, 21 of
them one module's (the agent's configuration, where *node* is also a scope's value).
- The console's own tools are `mesh_machine` and an overview that lists *"machines"*. The controller's verb
is `nodes` and answers *"Every **machine** the mesh knows"*; `node` answers *"What one machine
reported"*. Every seat a machine holds is named `node-…`.
- The glossary itself switches inside one entry: *control-node* is *"the one node that also holds…"*, and
the next sentence says it *"is not a separate kind of machine"*.
**Proposed word: _machine_** (Placement). It is what the operator, the tools and the newest records already
say, and it needs no explaining. *Node* survives only as an **identifier**: the value of a scope
(`scope: node`), the `node-` prefix of seat names, and *control-node* until it is renamed. The glossary
would map the identifier to the word, as it does for `mesh-host` today. The alternative — *node* everywhere —
is defensible and cheaper in code, and is what the glossary says; it is listed in
[00](00-overview.md) as the operator's call because either choice renames something heavily used.
### 2. *node-engine*, *mesh-host*, *the host*, *node host*, *host agent* — one program (synonym)
**Evidence.**
- The glossary retired *host agent*, *the host* and `mesh-host` for **node-engine** on 2026-10-05, keeping
`mesh-host` as the name of the repository, binary and service unit until they are renamed.
- Its own node tools entry still says *"a host-side process **the host** supervises"*.
- To-be 05 is titled *the node host*; *the host* is in 34 to-be designs and *node-engine* in 7.
- Of the decision records written since the retirement, ADR 0222 says *the host* six times, ADR 0236 six
times beside *node-engine* nine times, and ADR 0223 three of each.
- Tool descriptions: `<machine>/node-service-manager.start` and `.stop`, and the container module's
`start` and `stop`, say *"the answer says **the host** will restore …"*. The bus server's `connections`
tool says *"a machine's **host** `node.<machine>`"* — naming the program *host* and its bus account
*node*.
- *Host* also keeps its ordinary meanings: a network host (the ssh client module's tools), *hosting* a
service, and the *host's* network in a container runtime.
**Proposed word: _node-engine_** (Core), as the glossary already says; *the host* and *host agent* retired
for the program. Because *host* also has ordinary networking meanings, the check in
[04](04-how-the-glossary-is-checked.md) can retire only the phrases *the host* and *host agent* and the
identifier `mesh-host` outside code spans, not the bare word. If clash 1 is decided for *machine*, the
program's name reads more naturally as **machine engine**; this effort does not propose renaming it again.
### 3. *plan* — four things (homonym), and *change plan* / *release plan* / *delivery plan* (synonyms)
**Evidence.** On one seat, the controller's:
- `mesh-controller.plan` — *"What one machine would run, and why: the declaration the mesh would send it"*.
A **machine's declaration**, previewed.
- `mesh-controller.plans` — *"What the last merges produced and where each stands (ADR 0162): the
tiers"*. ADR 0162 calls it *a tiered plan*; ADR 0218 and ADR 0236 call it the **release plan** (seven
times in ADR 0236: *"Every release plan's first machine passes a gate"*).
- `mesh-controller.delivery-plan` — *"The delivery plan of a diffset"*. The glossary: *"ADR 0238 called it the
**change plan**; that word is retired"* — and the walk replaces the release plan: *"the controller's
plan is now the walk of one delivery's trunk commit across machines"*.
- `mesh-controller.bus` — *"The bus as a **planned step**"*.
*Release plan* appears in seven files created on or after 2026-10-06, the day ADR 0239 replaced it; *change
plan* in four, including the title of ADR 0238.
**Proposed words** (Change and delivery, except the first):
| Concept | Word | Retire |
|---|---|---|
| what the controller would send one machine | **declaration** (Placement); the verb `plan` becomes `declaration` or `preview` | *plan* for this |
| what a delivery would do to the mesh | **delivery plan** | *change plan*, *release plan* |
| the controller's sending of one commit's builds across machines | **walk** | *release plan*, *plan* for this |
| a step a person starts | **planned step**, or simply *step* | — |
Bare *plan* is then never a mesh concept on its own.
### 4. *push*, *send*, *roll out*, *release*, *deploy*, *upgrade* — moving a change (synonyms and homonyms)
**Evidence.**
- **push.** `mesh-controller.push`: *"Send one machine everything it should be. With no machine named it
is a push of the WHOLE mesh"*. ADR 0221's title: *"a push sends no build a policy or a plan holds
back"*. The same word is a git push (which, per the conventions, starts the build) and a phone
notification (*"Matrix once its push is measured"*, to-be 46). *Push* is in 84 decision records and
152 issues.
- **send.** ADR 0236: *"that machine is sent it, by the ordinary send"*; the gate is judged *"from the
moment it was sent the build"*. The controller's own description of `push` uses *send*.
- **roll out.** ADR 0236's title says *rolls out unattended*; its person-facing verb is `upgrade <module>
roll-out`, and the manifest's policy for the same thing is spelt `roll`. ADR 0218: *"code rolls out one
machine first"*. *Roll out* is in 30 decision records and 36 issues.
- **release.** `mesh-delivery.release` — *"A person's word that a held delivery goes on"*;
`anthropic-licence-manager.release` — *"Unbind a consumer"*; and the retired *release plan* (clash 3).
- **deploy.** The glossary: a delivery is *"not 'deployment' (one stage of it)"*; its *delivery plan* holds
a *deploy plan*. The controller has no verb named deploy, and the only tool that says *deploy* is a
wrapped flow editor's.
- **upgrade.** `mesh-controller.upgrade` — *"How each module's new builds reach its machines … rolled out
one machine first"*: a **policy**, not an act.
**Proposed words:**
| Concept | Word | Domain |
|---|---|---|
| the controller giving one machine its declaration | **send** (the verb `push` asks for one) | Placement |
| one commit travelling to every machine | **delivery** | Change and delivery |
| the per-machine part of a delivery plan | **deploy plan** (kept; the only use of *deploy*) | Change and delivery |
| a build going to its first machine, judged, then the rest | **walk** (the act); **roll** (the policy, one spelling in the manifest and in the verb) | Change and delivery |
| a person letting a held delivery go on | **release** — this sense only | Change and delivery |
| unbinding a licence's consumer | **unbind** (rename the licence verb) | Identity |
| how a module's builds reach its machines | **upgrade policy** | Module |
*Push* is kept for the controller's verb because it names what the operator does, but prose says *send*;
*roll out* becomes the policy's value `roll` and is not a noun.
### 5. *console*, *node tools*, *mesh-console*, *the MCP server* — the operator's surface (synonym and homonym)
**Evidence.**
- The glossary's surfaces section: the console is *"the module (`mesh-console`)"*; not *"the tool bridge",
"the brain" or "the MCP server"*.
- The glossary's operator's machine section: node tools *"replaces 'console' as the module's name;
console remains the word for the person's end of it"*. Both entries are current.
- The live console introduces itself as *"The tools of a Novox mesh, reached as `<machine>.node-tools`"*,
under an MCP server named `mesh`; the organisation's instructions to the agent call that server *"the
console"*.
- *Console* is in 28 decision records; `mesh-console` in 1; *node tools* in 10. A wrapped chat program's
tool and the localisation module's tool use *console* in their own senses (a virtual terminal, a web
console).
**Proposed words:** **node tools** (Core) for the runtime that serves every module's tools and every seat's
verbs on a machine; **console** (Operator and conversation) for the end of it a person or agent uses — the
MCP endpoint and its five tools. `mesh-console` retired. The glossary's surfaces entry is rewritten at
graduation; the contradiction is removed, not annotated.
## Change and delivery
### 6. *gate* — three things (homonym)
ADR 0136 (*a step gates its module, not the machine*): a failing step of a module's apply stops that
module's later steps. ADR 0236: the **gate** on a delivery's first machine — *"healthy three times"*.
ADR 0237 and ADR 0238: the **merge gate**, a status a pull request carries (`mesh/merge-gate`). The
controller's description of `upgrade` says *"judged there at the gate"*; a wrapped forge's tools say
*branch protection*. **Proposed:** *first-machine gate* for ADR 0236's, *merge gate* for the status, and
ADR 0136's becomes *a failed step holds its module* (no noun). Bare *the gate* is not used.
### 7. *ask* — the operator's question, and an entry in the build queue (homonym)
The glossary: *"**ask** — a request for the operator's input, of a declared kind"* (ADR 0234).
`mesh-controller.queue`: *"Every **ask** in the build queue: waiting, in flight … and dead"*;
`.cancel`: *"Drop one waiting or dead ask"*; `.clear`: *"Cancel every waiting ask"*. ADR 0234's ask is in
58 decision records' worth of prose shared with the ordinary verb *to ask*. **Proposed:** *ask* stays the
operator's (Operator and conversation); an entry in the build queue is a **build request** (Change and
delivery), and the three verbs' descriptions say so.
### 8. *tier* — three things (homonym)
The `topic: the tiers` of 36 decision records, and `repos.md`'s *Tier 0 … 3*: the **layers** of the mesh
(node-engine, foundation, controller, surfaces). ADR 0162 and to-be 47: the **steps of a walk**, a module
and the modules built against it (*"its builds are asked and registered tier by tier"*). To-be 46: the
**levels of authority** of an ask (*"a kind below the tier"*, *"no P2 tier before recovery codes"*).
**Proposed:** *layer* for the first (Core); *tier* for the walk's steps (Change and delivery), which is how
the tools use it; *assurance level* for the third (Identity and access).
### 9. *declaration* — what a manifest states, and what the controller sends (homonym)
The glossary: the node-engine *"receives the node's **declaration**"* — the controller's per-machine
document. The glossary again: a module *"**declares** resources"*, and *depends on a seat* is *"derived from
the resources it declares"* — the manifest. `mesh-controller.plan` returns *"the declaration the mesh would
send"*; the container and service-manager tools say *"what its declaration says"*. *Declaration* is in 106
decision records, in both senses. **Proposed:** a manifest **declares** (Module; the noun is *the
manifest*), and what the controller sends one machine is that machine's **declaration** (Placement). The
verb and the noun then never name the same thing, and *declaration* is not used for a manifest.
### 10. *control plane* and *controller* (synonym, drift)
Retired by the glossary on 2026-09-16. Since then: 29 occurrences in 12 to-be designs (`05`, `07`, `08`,
`18`, `19`, `26`, `28`, `29`, `30`, `32`, `33`, `01`) and one as-is design; 73 files created after the
retirement use it — 36 issue reports, 29 decision records, 6 designs — among them issue 156, titled *moving a consumer's delivery subject
stops the control plane*. ADR 0006, the record the word came from, still has it in its
title. **Proposed:** *controller* (Core), as decided; the to-be designs are corrected at graduation, which
is a link-and-word fix the README's rules allow.
## The record and its checks
### 11. *record* — four things (homonym)
A **decision record** (this repository). **The record** of the mesh — the `records` module that agents
search first (*"search the record with the literal text"*), and ADR 0006's *"where the record lives is
deliberately open"*. An **upgrade policy** value: `record` means *register the build, send it nowhere*
(ADR 0236). **On record**: a seat holder the controller has written down (ADR 0223's *"each on record"*).
**Proposed:** *decision record* always qualified in hq; *the record* for the mesh's knowledge base (Operator
and conversation, or a domain of its own if the records module grows one); the policy value renamed
**hold** — which is also what *held* means for a delivery — and *on record* left as ordinary English.
### 12. *check* — at least six things (homonym)
The checks in `00-META/checks/`; a pull request's **merge check**, made of the **merge gate** and the
**repository check** (`mesh/repo-check`, ADR 0238); a delivery group's **composed check**
(`mesh-controller.delivery-check`); the state `checked` of a delivery; the **self-check** (clash 13); a
module's tool named `…_check` (the ssh client's). *Check* is in 92 decision records. **Proposed:** never
bare in a governing document; always one of *merge check*, *repository check*, *composed check*,
*self-check*, *hq check*. The word is too ordinary to retire, so this one is a writing rule, not a list
entry ([04](04-how-the-glossary-is-checked.md) says why it cannot be checked mechanically).
## Health and repair
### 13. *self-check* and *doctor* (synonym)
To-be 45 names the concept **self-check** (8 occurrences) and the verb `doctor` (14); the verb's own
description begins *"The self-check (to-be 45 §4)"*. **Proposed:** *self-check* (Health and repair); the
verb renamed `self-check`, or kept as `doctor` with the glossary mapping it — a verb name is an identifier.
### 14. *condition*, *alert*, *problem*, *incident*, *finding* (synonyms)
The controller's verb `conditions`: *"What is wrong with the mesh now: every open condition"*; to-be 45 says
*condition* 72 times. ADR 0006's observability context owns *"health, logs, metrics, **alerts**"*. ADR
0224's title: *"a provider that keeps failing a consumer is a **problem** the controller reports"*. The
container module's tool is `docker_problems`. *Incident* is in 14 decision records, mostly as *the incident
that earned a rule*. *Finding* is in 19, as what a check or a probe found. **Proposed:** *condition*
(Health and repair) for an open fact about something wrong; *finding* for what one probe or check returned
before it becomes a condition; *incident* kept for a past event that taught a rule (The record); *alert* and
*problem* retired for the mesh's own notion. A wrapped dashboard's *alerts* are its own.
## Connectivity
### 15. *private network* and *overlay* (synonym)
The operator, quoted in ADR 0226 (2026-10-06): *"we simply have a private network"*; ADR 0226's title and
the `mesh-wireguard` module's role: *the private network*, in 37 decision records. To-be 08 (connectivity)
says *overlay* 24 times; *overlay* is in 30 decision records and 13 to-be designs. No tool says either.
**Proposed:** *private network* (Connectivity), the operator's word.
### 16. *packet filter* and *firewall* (synonym)
The seat every machine holds is `node-packet-filter`, with the verb `rules` (*"The packet filter as this
machine enforces it now"*). The nftables module's tool is `firewall_rules`. *Firewall* is in 33 decision
records and 13 to-be designs; *packet filter* in 22 and 12. **Proposed:** *packet filter* (Connectivity),
after the seat; the module's tool renamed `packet_filter_rules` or described in the seat's word.
## Operator and conversation
### 17. *operator*, *the user*, *person* (synonym, partly)
The glossary's *operator account* entry: *"Not 'the user' (ambiguous with a module's own account)"*. *The
user* is in 10 to-be designs, several meaning a bus account (*"the user list"*, the bus server's word), and
in 9 decision records. *Operator* is in 125 decision records and the tools of 23 modules; *person* in 91
records, meaning a human as against the mesh acting unattended (*"a person's word"*). **Proposed:**
*operator* for the person who runs the mesh; *person* for any human acting where the mesh could have; *the
user* retired in the mesh's own prose; a bus account is a *bus account* (Identity). A wrapped program's
*users* are its own.
### 18. *agent* — three things (homonym)
The glossary avoids *agent* for the node-engine because it *"already means two other things here, the build
agent and the operator's coding agent"*. The seat every machine holds is `node-build-agent`, held by the
`build-agent` module; ADR 0237 calls the role *the build seat*, ADR 0236 *the build machine* (14 decision
records). The licence manager's verbs say *"a node's **agent**"* for the coding agent a licence is bound to.
A wrapped memory server has *agents* of its own. **Proposed:** *agent* only for a coding agent (the
operator's, or one the mesh runs); the build role is the **build seat**, its holder on a machine the
**builder** — and the seat renamed when seats are next renamed. *Build machine* retired: any machine may
hold the seat.
## Core
### 19. *store* — four things (homonym)
The glossary: **store** — *"the one postgres server"*. ADR 0189's title, *"The store keeps what the
records name"*, is about the **artifact store**. Research 015 is about the **object store**. ADR 0006: *"a
node reconciles from its own store"* — the machine's local copy of its declaration. **Proposed:** *store*
alone means only the foundation's database server (Core); the others are always *artifact store*, *object
store*, and the machine's *last declaration*.
### 20. The glossary's own contradictions
Recorded in [01](01-the-concepts-in-use.md) and listed here because the fix is the same kind: the console
(clash 5); the deprecated broker, which has no seat in its own entry and claims `mesh-broker` in the entry
for *claim*; and node tools described with the retired *the host*. **Proposed:** fixed at graduation,
in the same change that reorganises the glossary ([05](05-a-glossary-by-domain.md)).
## Checked and found consistent
Worth saying, so nobody repeats the search:
- **catalogue / catalog.** Prose says *catalogue* everywhere (no bare *catalog* in any decision, design or
`00-META` file); *catalog* appears only inside identifiers (`mesh-catalog`, `catalog_modules`). That is an
identifier, not a clash.
- **foundation / substrate.** Of the 10 files created since *substrate* was retired that still use it, 9
are decision records and research (allowed to keep their words); the tenth is the glossary, naming it
as retired.
- **flavor.** Retired; two to-be designs (08, 16) still say it, both predating the retirement.
- **operator** in the tools is used consistently for the person, in 28 tools of 23 modules.
@@ -1,140 +0,0 @@
# 04 — How the glossary is checked
[`AGENTS.md`](../../AGENTS.md): *"If a document states a rule about the mesh, it says how that rule is
verified. An unenforced rule is indistinguishable from a wrong one."* The glossary states two rules — *one
name per thing*, and *"a new name for an existing thing lands here first, in the same change that
introduces it in code"* — and nothing verifies either. [03](03-the-clashes.md) is what that costs. This
document describes a check; it does not build one. Building it follows graduation.
## What a word list can and cannot catch
A list of retired words catches **synonyms**: a word the glossary replaced, used again. It cannot catch
**homonyms** — *plan*, *gate*, *tier*, *ask*, *store*, *record*, *check* — because the word is right in one
sense and wrong in another, and telling them apart is reading, not matching. So the proposal has two
parts: a mechanical check for synonyms, and a rule for homonyms that turns each one, once decided, into a
synonym the list can hold (*release plan* → *walk*; *build machine* → *build seat*; `ask` in the build
queue's verbs → *build request*). A homonym left undecided stays a reviewer's job, and the glossary says
so next to the word.
## Part 1 — the list, kept in the glossary
The list of retired words lives **in the glossary, in one fixed form**, so there is one source and no
second file to drift from it. Every entry that retires words ends with one line:
> *Not:* ~~control plane~~, ~~substrate~~
and nothing else in the glossary is struck through. Today the glossary marks retired words three ways
(struck through, *"Replaces x"*, *"Not x"*); graduation moves all of them to this line.
Each struck word may carry a **scope**, in brackets after it, saying where it is retired:
- *(hq)* — in this repository's governing documents only;
- *(tools)* — in the descriptions of the mesh's tools and seat verbs only;
- no scope — both.
The scope exists because some words are wrong in one place and right in the other: *the user* is retired
in hq prose, but a wrapped identity provider's tool must say *user*.
An entry may also carry an **identifier** line naming code that still has the old name, and the issue that
tracks its rename:
> *Identifier until renamed:* `mesh-host` — the repository, binary and service unit (issue NNN)
An identifier is allowed inside a code span (`` `mesh-host` ``) and nowhere else; when the issue resolves,
the line goes and so does the allowance.
## Part 2 — `00-META/checks/words.py`, over this repository
**Reads** the glossary's *Not:* lines and *Identifier* lines. **Scans** the documents that tell somebody
what to do — the same set `records.py` calls *governing*: `00-META/` (the glossary's own *Not:* lines
excluded), `03-DESIGN/01-to-be/`, `AGENTS.md` and `README.md` — plus `03-DESIGN/00-as-is/`, whose words
should be today's even where its facts are yesterday's.
**Fails** on a retired word (of scope *hq* or none) in running prose. It **ignores**:
- code spans and code blocks, which hold identifiers;
- block quotes and text in quotation marks, which quote somebody — the operator, a tool, an older
record — and must quote them faithfully;
- link targets, which are file names, and file names are immutable when they are a decision record's;
- `02-DECISIONS/` entirely, and `01-RESEARCH/` and `04-ISSUES/` written before the check's start date
— records keep their words, and research and issues observe.
**For research and issues written after the start date** the check applies the same rule. An issue report
quoting a log or a tool keeps the quotation; its own prose uses today's words. This is a choice for
graduation, listed in [00](00-overview.md); the reason to include them is that issue reports are the
largest single source of drift found (36 of the 73 files that say *control plane* since its retirement).
**The reverse rule.** A to-be design that defines a word in the glossary's form — a bolded word followed by
a dash at the start of a definition — must find that word as a head word in the glossary. This is how
*"a new name lands here first"* gets checked, and it would have caught *condition*, *healer* and
*data class* arriving in to-be 43 and 45 with no entry.
**The uniqueness rule.** No head word appears in two entries, and no head word is also struck through in
another entry. This would have caught *release*: today a verb of `mesh-delivery`, and the retired *release
plan*.
**What it would fail on today** — the check is proposed because these are real, and per
[`00-META/checks/README.md`](../../00-META/checks/README.md) a check must fail on something real before
it passes:
| Retired word | Where, today |
|---|---|
| control plane | 29 occurrences in 12 to-be designs; one as-is design |
| the host (for the node-engine) | 34 to-be designs, and the glossary's own node tools entry |
| release plan, change plan | to-be 45, 47 |
| flavor | to-be 08, 16 |
| the user | 10 to-be designs (several are a bus account) |
Each is either fixed at graduation or, where it is a quotation, put in quotation marks — which is the
check doing its job, making a quotation look like one.
## Part 3 — the same list, over the catalogue's tool descriptions
An agent meets the mesh's words most often in tool descriptions: 535 module tools and the verbs of 20 seats
today. Nothing compares them with the glossary, and four of them say *the host*.
**Where it runs.** In the catalogue repository's own repository check (`mesh/repo-check`, ADR 0238), which
already reads every changed manifest. A changed manifest's tool descriptions and its seat's verb
descriptions are matched against the *Not:* words of scope *tools* or none. A finding fails the check, as
everything does on the mesh's repositories (a warning blocks a merge the same as a failure there, [issue
293](../../04-ISSUES/293-a-warning-on-a-required-check-blocked-the-merge/00-report.md), so a warning-only mode
is not available and is not proposed).
**Where the list comes from.** The catalogue cannot read this repository at build time without a new
dependency. Two options, for graduation:
1. **A copy, checked.** The catalogue keeps a copy of the list, and `words.py` here fails when the copy in
the catalogue's trunk differs from the glossary. Simple; two repositories change for one word, which is
honest about what a retired word costs.
2. **The list as an artifact.** This repository's merge publishes the list to the artifact store, and the
catalogue's check reads the newest. One source; one more thing that must be up for a catalogue merge.
The first is recommended: it fails loudly in the right place, and adds no runtime dependency.
**Vendor words.** A module wrapping a program describes that program's objects in its own words — an
identity provider's *users*, a media manager's *releases*, a flow editor's *deploy*. The list holds only
the mesh's own retired words, and a word that is also a common vendor word (*user*, *release*, *deploy*)
is never retired with scope *tools*. That keeps the check from crying wolf, which
`00-META/checks/README.md` names as the way a check gets suppressed.
**On the running mesh, later.** The descriptions an agent actually sees are the ones served, which may lag
the catalogue's trunk. A probe in the self-check could compare the controller's `tools` verb with the same
list and raise a condition. That is a second place for the same rule and is not proposed until the first
has run.
## Part 4 — homonyms, by rule
For a word the glossary marks *homonym* (*plan*, *gate*, *tier*, *store*, *record*, *check*, *ask*,
*agent*), the entry lists each sense with its qualified form and its domain, and the writing rule is: **a
homonym is never bare in a governing document; it is always the qualified form.** This is not
machine-checked — a word list cannot tell *the gate* that means a first-machine gate from one that means
the merge gate — and the glossary says so next to the rule, as AGENTS.md requires: *"checked by review;
the reviewer looks for the bare word in the diff."* Where a homonym is resolved by renaming one sense, the
old sense moves to the *Not:* line and becomes mechanical.
## What it costs
- One Python file of the size of `cycle.py`, run by `merge-check.sh`.
- A one-time correction of the governing documents it fails on, made in the graduation change.
- A copy of the list in the catalogue, and a few lines in its repository check.
- One rule for writers: a retired word may be quoted, never used.
@@ -1,101 +0,0 @@
# 05 — A glossary by domain
How [`00-META/glossary.md`](../../00-META/glossary.md) would be organised once the domains in
[02](02-the-domains.md) are decided. Described only: the glossary changes in the graduation change, with
the decision record that adopts the domains, and not before.
## What is wrong with its shape today
- **Its sections are a history, not a map.** *The operator's machine* holds node tools, bundle, kept region
and installed / holding because they arrived with research 018, not because they belong together; node
tools is a Core concept and kept region a Module one.
- **It has no home for a new word.** A writer adding *condition* would not know which section it goes in,
and so it went in none.
- **Retired words are marked three ways**, so no program can read them ([04](04-how-the-glossary-is-checked.md)).
- **It is one page for 33 entries**; with the roughly 50 words [01](01-the-concepts-in-use.md) found
missing, it is about 85, and one flat list of 85 is read by nobody.
## The proposed shape
One file, as now — it stays the single authority, and a reader searches one page. Its sections are the
domains, in the order of [02](02-the-domains.md) (most upstream first), preceded by two short sections.
### 1. How to read this page
What a domain is, in two sentences; that a word is defined once, in the domain that owns it; that other
domains may use it but not change it; and the forms of the *Not:* and *Identifier* lines the check reads.
### 2. Words every domain uses
A short **shared kernel** — the handful of words with one meaning everywhere, owned by no single domain
and changed only by a decision record: *mesh*, *machine* (or *node*, clash 1), *module*, *seat*, *operator*,
*person*. Each points to the domain where its detail lives. Kept deliberately short: a word that only one
domain refines belongs to that domain.
### 3. One section per domain
Each section opens with a fixed header, then its entries:
- **Purpose** — one sentence, from [02](02-the-domains.md).
- **Owned by** — the context, module or seat that records its concepts (*the controller's `inventory`
context*; *the `mesh-delivery` module*; *every machine's `node-backup` seat*).
- **Upstream of / downstream of** — the domains, by name.
- **Decided by** — the decision records that define the domain's words, so a reader can go from a word
to why.
Each **entry** keeps today's prose form and gains at most three fixed lines:
- the definition, in plain sentences, with its record cited (as now);
- *Not:* ~~retired word~~, ~~another~~ *(scope)* — the words it replaced;
- *Identifier until renamed:* `old-name` — what code still carries the old name, and the issue that
tracks the rename.
A word a domain uses but does not own is not repeated there; the domain's section may list it under *Uses*
with a link, so a reader of one section sees its whole vocabulary without the definition being copied.
### 4. Homonyms
One section at the end for the words that mean different things in different domains — *plan*, *gate*,
*tier*, *store*, *record*, *check*, *ask*, *agent*, *release*. For each, a small table: the sense, its
qualified form, the domain that owns it. This is the page a reader lands on from a search for a bare
word, and it says the rule beside the table: never bare in a governing document; checked by review.
### 5. How this page is kept
Today's section, extended: a new word lands here first, in the domain that owns it; a word moves domain
only with a decision record; a retired word goes on a *Not:* line and nowhere else; and `words.py`
checks the three rules it can ([04](04-how-the-glossary-is-checked.md)).
## Where today's entries would go
| Today's entry | Domain |
|---|---|
| node, control-node, operator account | shared kernel (machine); Placement (operator account); Core (control-node) |
| controller, mesh-controller, node-engine, foundation, store, bus, the deprecated broker | Core |
| package, artifact | Change and delivery |
| seat, depends on a seat, installed / holding | Placement |
| claim, provision (as declared), invokes, bundle, kept region | Module |
| provision (as resolved) | Provisioning |
| channel / intake, ask, operator message / input, reference, console | Operator and conversation |
| node tools | Core |
| delivery, delivery group, delivery plan, mesh-delivery, walk | Change and delivery |
| ~~master / slave~~, ~~hub / peer~~, ~~flavor~~ | *Not:* lines on *controller* and *setting* |
And the sections that are empty today and would be filled from the records that already define their
words: **Identity and access** (credential, secret, vault, grant, bus account, licence, binding, proof —
ADRs 0143, 0183, 0225, 0234); **Health and repair** (condition, probe, signal, watchdog, self-check, healer,
drill, hand-act — to-be 45, ADRs 0227, 0231, 0240); **Data** (data class, backup, restore point, stream
snapshot — to-be 43, ADRs 0233, 0235); **Connectivity** (private network, resolver, uplink, packet
filter, reach — ADRs 0117, 0138, 0223, 0226).
## What derives from it
- **The decision index.** The records' `topic:` (*the mesh*, *the tiers*, *what runs on it*, *building
it*, *checking it*, *how we work*) predates the domains and partly overlaps them. Whether a record also
names its domain — a new frontmatter field, and so a schema change — is left for graduation. It would let
`words.py` scope a word by domain, and `index.py` print the reading order per domain.
- **The constitution page.** The mesh injects a page derived from `how-we-build.md` into design sessions
(playbook 05). A derived glossary page per domain could be injected the same way, so an agent starts
with the words; that is a separate decision, not proposed here.
- **Tool descriptions.** Once a domain owns a word, a module describing that concept in a tool uses it; the
check in [04](04-how-the-glossary-is-checked.md) holds them to the retired half, and review to the rest.
@@ -1,85 +0,0 @@
---
status: active
initiated: 2026-10-08
touches:
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
- 02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md
- 03-DESIGN/01-to-be/51-the-registries-keep-what-is-named.md
- the artifact store, the catalogue's modules that pin an image by digest
---
# 035 — The images from before the mesh
## What is investigated
The artifact store's tools ([ADR 0251](../../02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md))
counted 469 unrecorded manifests marking 23.8 GiB on 2026-10-08. 374 of them are copied bases
([issue 321](../../04-ISSUES/321-a-copied-base-was-recorded-by-nothing-and-a-let-go-index-kept-its-platforms/00-report.md),
[ADR 0257](../../02-DECISIONS/0257-a-copied-base-is-recorded-where-it-is-copied-kept-by-the-builds-that-stood-on-it-and-held-once-per-image.md)).
Most of the rest are images pushed to the same store before the mesh existed. The predecessor's
pipeline built them, under a namespace per owner, one repository per app.
No record of the mesh names these images, so [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
§3 keeps every tool away from them. That is correct: the mesh did not make them. It also means they stay
until a person decides. This effort finds out, for each one, whether anything still uses it, and
recommends what to do. Nothing was deleted.
## How it was measured
All of this was read, nothing was written. On 2026-10-08:
- **The store's view.** `artifact_store_references` and `artifact_store_repositories` give each
repository's manifests, tags and sizes. They give no dates.
- **What each machine declares.** The controller's `images` verb answers for each of the four machines.
- **What each machine runs.** The container runtime module's container list answers on each machine.
- **What still names them.** I searched every repository on the forge for each image's path and digest.
There are 50 repositories, and I read 31 of them closely: both catalogues, the apps' own repositories,
the setup repositories and the predecessor's.
- **Dates.** No tool gives a manifest's push time. A date below is either an image's `created` date, from
a copy still in a machine's local image store and matched by tag, or the date of the commit the tag
names. Where neither exists, the date is not known, and the table says so.
## What was found
[01-the-images.md](01-the-images.md) has the table. In summary:
- **3 repositories hold an image a machine runs today.** Each is one of three app images on the anchor:
an invoicing app's API, its web front, and a static company website. A catalogue module pins each by
digest, under an older name of the same store. Each repository also holds two older tags that
nothing uses.
- **2 repositories are pinned only by catalogue modules that no machine is assigned.** One is a
magazine site's older image. The other is an older photo client, pinned by two per-person photo
modules. Both are stale definitions.
- **35 repositories, plus 2 single manifests in repositories the mesh builds, are used by nothing.**
Nothing runs them, declares them or pins them.
- Most were superseded by a module that builds its own image of the same app.
- The rest are abandoned: a trading bot, a chatbot, test images, the predecessor's dashboard and flow
engine, and the bootstrap's first builder and controller images.
- Together they mark between 5.9 and 7.5 GiB that nothing else marks. The two bounds depend on how the
blobs they share among themselves are counted.
**A finding beside the question.** The store's tools classify the three running images as
*unrecorded*, the same word they use for an image nothing uses. The reason is that the catalogue pins
them under an older name of the store, and the mesh did not make them. Nothing removes an unrecorded
manifest today, so nothing is at risk. But any later decision to let unrecorded images go by some
other rule would take running images with it. That is recorded as
[issue 323](../../04-ISSUES/323-an-image-a-definition-pins-under-an-older-name-of-the-store-reads-as-unrecorded/00-report.md).
## What is recommended
- **Keep** the three running images. Point their modules at an image the mesh builds, where one exists,
so the running images become the mesh's own. Two of the three apps already have a build of their own
on a branch.
- **Delete the two stale catalogue modules**, then treat their images like the rest.
- **Let go of the 35 repositories and 2 manifests** once a decision says how an image the mesh did not
make may be removed. ADR 0189 §3 forbids every tool from doing it today, and that is intended. The
decision is the operator's: the images are the operator's, and some can no longer be rebuilt.
- **Adopt none of them as a module.** Every app in use already has a module.
## Open questions
- Should an image the mesh did not make be removed through the controller's records, by a person
recording it (as ADR 0257 lets a person record a copied base), or through a separate verb of the
store's tools?
- Should the older tags in a repository that is in use (about 0.8 GB together) go before their modules
are moved to the mesh's own build, or with it?
@@ -1,64 +0,0 @@
# 035 — The images, one by one
Read on 2026-10-08. Sizes are the bytes a repository's manifests mark. *Only theirs* is what no other
manifest in the store marks, which is what removing the repository would free. A machine is named by its
role.
Dates: *created* is an image's build date, from a copy in a machine's local image store matched by tag;
*commit* is the date of the commit the tag names; *not known* means neither was found.
## In use
| what | size | manifests | newest | in use by | named by | recommendation |
|---|---|---|---|---|---|---|
| an invoicing app's API | 1.41 GB | 3 tags | created 2023-01 | the anchor, its API container (newest tag, matched by digest) | its catalogue module, pinned under an older name of the store; also the frozen runtime base of the app's own build on a branch | keep the newest; let the two older go (about 0.7 GB); move the module to the mesh's own build |
| the same app's web front | 60.5 MB | 3 tags | created 2023-01 | the anchor, its web container (matched by digest) | its catalogue module, likewise | keep the newest; let the two older go |
| a static company website | 474 MB | 3 tags | created 2022-08 | the anchor, the website container (matched by digest) | its catalogue module, likewise; the site's repository has its own build on a branch, not yet delivered | keep the newest; let the two older go (about 75 MB) |
## Pinned only by a stale module
| what | size | manifests | newest | in use by | named by | recommendation |
|---|---|---|---|---|---|---|
| a magazine site, its older image | 393 MB | 3 tags | created 2025-10 | nothing: the anchor runs the mesh-built image | a catalogue module whose definition no machine uses | let the two older go; the newest once the stale module is deleted |
| a photo app's older client | 11 MB | 1 | created 2024-10 | nothing: the anchor runs the mesh-built client | two per-person photo modules, assigned to no machine | delete the two modules, then let it go |
## Used by nothing
Each row is one repository, unless it says otherwise.
| what | size (only theirs) | manifests | newest | superseded by, or why abandoned | recommendation |
|---|---|---|---|---|---|
| a car-listing scraper | 2.31 GB (1.43 GB) | 3 | commit 2026-02 | the module's own build, which the home server runs | let go |
| a chatbot's API | 3.04 GB | 1 | before 2024-09 | abandoned since 2024 | let go |
| a chatbot's web front | 19.8 MB | 1 | before 2024-09 | abandoned | let go |
| a comic app's API and web front (2 repositories) | 828 MB | 1 each | created 2026-09 | the predecessor's; no module | let go, unless the app is wanted back (then build it as a module) |
| a photo app's earlier API and web front (2 repositories) | 519 MB | 4 each | not known | the photo modules | let go |
| a photo app's server and admin client (2 repositories) | 445 MB | 1 each | created 2024-10 | the photo modules' own builds | let go |
| a map game's API and web front (2 repositories) | 121 MB | 3 each | commit 2026-02 | renamed to the travel game below | let go |
| a travel game's API and web front (2 repositories) | 104 MB | 2 each | commit 2026-02 | no containers or data remain | let go (it can be rebuilt from its repository) |
| a racing-circuit site | 264 MB | 3 | created 2026-06 | the module's own build, which the home server runs | let go |
| an older company website | 400 MB | 1 | not known | the static website above, and its own build | let go |
| a payment-link site | 52.6 MB | 3 | before 2025-11 | the module's own build | let go |
| a mail-forwarding service | 58.5 MB | 1 | created 2026-03 | its repository's own module, assigned nowhere | let go |
| a snake game and a text game (2 repositories) | 88 MB (0.8 MB) | 1 each | created 2026-08 and 2026-09 | the modules' own builds | let go |
| a trading bot | 85 MB | 3 | commit 2022-08 | abandoned | let go |
| a trading bot's scheduled jobs and web front (2 repositories) | 83 MB | 1 each | not known | abandoned (setup last changed 2025-01) | let go |
| three base images of a former owner namespace (3 repositories) | 188 MB | 1 each | not known | abandoned (setup last changed 2023-05) | let go |
| test images (3 repositories) and a capability test | 135 MB | 1 to 3 | not known | tests of the predecessor | let go |
| the predecessor's dashboard and flow-engine API (2 repositories) | 165 MB (157 MB) | 2 and 3 | created 2026-09 | the predecessor is retired | let go |
| the bootstrap's first builder and controller images (2 repositories) | 30 MB (26 MB) | 1 each | created 2026-09 | the build agent; the controller runs as a bundle | let go |
| a build agent's and the controller's former server images (2 repositories) | 62 MB (55 MB) | 2 each | created 2026-09 | the same | let go |
| one manifest in the mesh-built tools image's repository | 87 MB (6.6 MB) | 1 of 4 | not known | the three recorded beside it | let go |
| one manifest in the route proxy's server repository | 9.9 MB (6.0 MB) | 1 of 6 | not known | the kept build the machines declare | let go |
**Together**, the rows used by nothing mark between 5.9 GiB and 7.5 GiB that nothing outside them marks.
The upper bound sums each repository's own bytes. That counts twice a blob two of them share. The lower
bound subtracts every shared blob, and that also subtracts blobs shared only among these rows, which
removing all of them would free. Removing the two stale modules' images adds 0.4 GiB. Removing the older
tags of the three images in use adds about 0.8 GB.
**Not determined.**
- A push date for 14 of the repositories.
- Whether the forge listing of 50 repositories is all of them. A second page came back empty, so I think
it is.
@@ -1,130 +0,0 @@
---
status: active
initiated: 2026-10-08
touches:
- the bus
- 03-DESIGN/01-to-be/25-the-bus-on-nats.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
- 03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md
- 03-DESIGN/00-as-is/14-a-modules-state.md
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- the controller's records
- the catalogue's module manifests
became: []
---
# 036 — Everything moves over NATS
## What is investigated
This effort asks two questions:
1. Where do the mesh's modules still talk to each other around the bus?
2. What does a **view** onto the bus need? A view is a web app or a terminal UI built from the bus
alone. *View* is proposed as a glossary word for this.
The operator set three principles on 2026-10-08:
1. **All communication between modules goes over NATS.** A module may keep its own state. Another
module reading its files, calling it over HTTP or a socket, sharing env files, running `docker exec`
in its container, or using ssh is a gap. Only a channel's outside link, an external API, leaves the
bus.
2. **The approval flow, for a person's decisions, is NATS-native and generic.** Another effort is
designing it. This one lists only how it fits ([evidence §6](01-evidence.md#6-how-the-approval-flow-fits)).
3. **Views are built from the bus.** State lives in key-value buckets, with events that say what
changed, and history lives in streams. A browser reaches the bus by NATS WebSocket and an auth
callout. Every module's important state should therefore be watchable on the bus.
## Why
[Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 already says "the bus is where the mesh
happens", and that conditions and observed state belong in buckets anything may watch. Two parts of
that are now built:
- **Buckets for a module's state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)).
- **The controller's conditions**, kept on the bus.
This effort measures how far the rest is from the principles, so that the next decisions start from
evidence.
## What was found
The detail is in [01-evidence.md](01-evidence.md), whose gap table is §5. The target state is in
[02-utopia.md](02-utopia.md). These are the gaps, ranked by how much they stand between the mesh and
the three principles:
1. **Most of the controller's state cannot be watched (G2).**
- Plans, builds, artifacts, node facts, assignments, settings, data items, heals, durations and
licences live in the controller's databases.
- They can be read only by calling a verb again and again.
- Settings change without any event, and `settings_history` holds 0 rows.
- Only the conditions follow the bucket-plus-event pattern a view needs, and they are the template
for the rest.
2. **Nothing outside the private network can reach the bus as a person (G1).**
- There is no WebSocket listener and no auth callout.
- There is one account of password users, composed into a static file and reloaded by signal.
- No grant is shaped for a view.
- Until this exists, no view can be built from the bus.
3. **The bus is one server with one copy of everything (G3).**
- There is no cluster and no leaf node, and every stream and bucket has one replica.
- A restart of the control-node stops every tool call, declaration and event in the mesh.
- A laptop away from the private network has no bus at all, even between its own modules.
4. **Modules reach each other through side doors (G5).**
- `artifact-store-tools` runs `docker exec` in the registry's container.
- The backup holder dumps stores by `docker exec`.
- Two modules hold the container runtime's socket.
- forticlient calls the resolver's verbs over a unix socket.
- route-adapter writes into the proxy's directory.
- Eight media modules carry copied code that calls other apps' HTTP APIs.
5. **Some mesh state and addresses live off the bus (G4).**
- The controller's facts snapshot is kept as a tag in the artifact store and read over HTTP.
- The lab is given the forge's address as a hand-written setting.
- What the registry holds is never announced.
- The bytes of artifacts and git repositories are a reasonable named exception. Their index is not.
6. **The bus does not watch itself (G7).** There is no system account and no dead-letter stream,
although design 25 promises one. These live numbers are unexplained and nothing raises them:
- 2 slow consumers;
- 4 688 JetStream API errors;
- 4 407 connections in 39 hours, against 16 open;
- about 40 writes a second to a delivery bucket of 998 keys.
*2026-10-08:* measured again and registered as issues
[326](../../04-ISSUES/326-mesh-delivery-writes-a-delivery-whether-or-not-it-changed/00-report.md) (the
delivery bucket's writes),
[327](../../04-ISSUES/327-every-controller-verb-opens-its-own-bus-connection-so-the-connection-count-hides-a-reconnect-loop/00-report.md)
(the connections), [328](../../04-ISSUES/328-jetstream-api-errors-are-counted-and-attributed-to-nothing/00-report.md)
(the API errors), [329](../../04-ISSUES/329-an-event-consumer-a-module-stops-declaring-stays-on-the-bus/00-report.md)
(the stale consumers) and [330](../../04-ISSUES/330-the-dead-letter-stream-design-25-promises-does-not-exist/00-report.md)
(the dead-letter stream). The slow consumers are recorded under
[issue 270](../../04-ISSUES/270-phase-1-watches-signals-that-come-later-and-hears-only-the-controllers-faults/00-report.md),
whose open question 2 they share.
7. **Smaller gaps:**
- Modules' own databases are invisible to views (G6).
- Buckets keep one value and events last a week, so history is short (G8).
- Lists asked of verbs meet the 1 MiB message limit (G9).
- The controller keeps two unused HTTP ports (G10).
**Much already passes the test.** The forge is wrapped by its module, and pull requests arrive as
events. No module calls the vault. No module uses ssh to reach another. Apps using a database or an
API through a provision are acceptable by the principle.
## What it touches
- **The bus's design and server configuration:** accounts, WebSocket, the callout, leaf nodes,
replicas and the system account.
- **The controller's records**, and which of them it mirrors to buckets and events.
- **What a module declares:** state, history streams, and tools in place of side doors.
- **The conversation with the operator**, where the approval flow meets a view.
## Open questions for the next step
1. **Order.** Which comes first: G2 for the controller, which makes views possible, or G1, which
makes them reachable? This effort suggests G2. It is useful to the mesh's own modules without any
browser.
2. **Accounts for people.** Does a person's view get its own NATS account, with imports from the
mesh account, or stay a user in the one account under a narrower grant? Design 25 §4 chose one
account when only modules were users.
3. **Which module serves the auth callout,** and which login it trusts: the mesh's identity provider,
or the proof of to-be 46 §10.
4. **Whether leaf nodes come before or after a cluster.** The laptop argues for leaf nodes first.
@@ -1,330 +0,0 @@
# 036 — Evidence: where the mesh still talks around the bus
Measured on 2026-10-08, read-only. Three sources were used:
- **The code.** The catalogue (116 module manifests and their code), the media catalogue (12), the
controller, the node-engine, the tool runner and the SDK, all at their trunk heads of that day.
- **The running bus.** It was read through the `nats` module's tools (`nats_server`, `nats_streams`,
`nats_buckets`, `nats_users`, `nats_connections`, `nats_subscriptions`, `nats_backlog`).
- **The running store.** It was read through `mesh-store.databases` and `mesh-store.query`, for table
names and row counts only.
No machine was logged into and nothing was changed.
The test applied throughout is the operator's first principle. **A module may keep its own state, but
another module reaches it only over the bus.** A module that reads another's files, calls it over HTTP
or a socket, shares an env file with it, runs `docker exec` in its container or uses ssh to reach it is
a gap. Only a channel's outside link, an external API, leaves the bus.
## 1. What is already on the bus
This section is the baseline, because most of the mesh already passes the test.
| fact | measured |
|---|---|
| server | one NATS server, release 2.11, on the control-node; 16 connections at the time of reading |
| streams | 21: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (7 days, 10 000 per subject), two seat work queues, and 15 key-value buckets |
| EVENTS | 183 205 messages, 143 MB, 5 260 distinct subjects, 19 durable consumers |
| key-value buckets | 15, owned by 5 modules: the controller (`calls`, `conditions`, `condition-history`, `hand-acts`, `lease`), mesh-delivery (`deliveries`, `groups`), the messenger (`open`, `sent`), claude-code (`config`, `holdings`, `servers`) and the licence manager (`bindings`), plus a `cancelled` bucket per build seat |
| events declared | 36 of 128 modules declare `emits`; 16 declare `consumes` |
| tool calls | every module tool and seat verb is core request/reply, `mesh.mod.<module>.tool.<tool>` and `mesh.seat.<seat>.tool.<verb>`, announced in the NATS services protocol (ADR 0197) |
| the forge's own state | the `gitea` module wraps the forge. Pull requests become events (`pull.merged`, `pull.updated`, `pull.closed`), and commit statuses are its tools. mesh-delivery reaches the forge only through those tools |
| vault | no module calls the vault. The node-engine unseals a secret into a file, and the vault's export travels in its node's declaration |
| ssh between modules | none. The ssh-client and claude-code modules only parse configuration (`ssh -G`) |
The controller's conditions already live on the bus. They are kept in `mesh-controller_conditions`
(802 keys) and `mesh-controller_condition-history` (90 days), and each change is announced as
`condition-raised`, `condition-changed` or `condition-cleared`. **They are the one complete example
of the pattern a view needs**: current state in a bucket, plus an event that says what changed.
## 2. Communication that leaves the bus
### 2.1 Acceptable by the principle
These are an app using a provision the way it would use any database or API, or the mesh talking to
the world outside:
- **Apps on their own provisions** (about 16 modules). Postgres serves baserow, gitea, keycloak, letta,
mailu, n8n, nextcloud and umami. The other provisions in use are mongo, S3, influx, MQTT, OIDC and
local model access, each through `${bound:<provision>:at}` and `:port`.
- **The route data plane.** About 30 modules are forwarded by the proxy over HTTP.
- **Desktop modules on the display server's socket.** Fifteen modules use it, and it never leaves its
machine.
- **Media apps sharing a library.** Nine modules share the media library directories.
- **External APIs** reached by a channel holder (the messenger's chat service) and by the licence
manager (the model vendor).
### 2.2 Gaps
The gaps are numbered for the table in §5.
**Docker into another module's container, and the container runtime's socket (G5).**
- **`artifact-store-tools`** runs `docker exec` inside the registry's container, which belongs to the
`distribution` module, to read what the registry holds.
- **The `nats` module's own tools** read `accounts.conf` with `docker exec … cat`, and fall back to
`docker exec … wget` when the monitoring port does not answer. These calls stay inside the module,
so they are not a gap.
- **The backup holder (restic)** runs each module's declared `dump` command. For postgres, mongodb,
mssql, nats and supabase, that command is a `docker exec` into the module's container. The command
is the owning module's own declaration, so the module still chooses what is read. The channel is
still a side door, though: the backup holder needs the container runtime's whole power over every
container.
- **The node-engine at genesis** reaches the controller with `docker exec <controller> mesh-controller
<subcommand>` and `docker cp`. It seeds gitea's role in the store with `docker exec <store> psql`,
and it reads the store's and the deprecated broker's administrator secrets from their files.
- **Two modules mount the container runtime's socket:** `build-agent` (read-write) and `supabase`
(read-only). The merge gate's check also mounts it into its check container. Anything holding that
socket can act as any other module's container.
**A unix socket and a shared directory between modules (G5).**
- **forticlient** dials the systemd-resolved module's verb socket to route its domains. That is a
module calling another module's verb without the bus.
- **route-adapter** writes route files into the proxy's (traefik's) watched directory.
**Mesh-written code calling another app's HTTP API (G5).**
- **sonarr, radarr, lidarr and bookshelf** carry identical provisioner code that calls nzbget's,
qbittorrent's and jackett's HTTP APIs to check a connection.
- **The plex API** is called from the tools of sonarr, radarr, lidarr, tautulli and ombi.
- **Status endpoints of the other apps** are called by ombi and bazarr, and home-assistant calls
sonarr's, radarr's and lidarr's.
These are eight modules in all. Each check is a question that the called module could answer as a tool
of its own. Today the code that asks it is copied into each caller.
**The artifact store over HTTP (G4).**
- **The controller** speaks the OCI registry's HTTP API to hold, tag, sweep and delete artifacts.
- **The builder** pushes images and archives, and mirrors upstream images.
- **The node-engine** fetches bundles and archives by URL.
- **The docker module** lists the registry, by address, as an insecure registry.
This traffic is bytes by digest, which a 1 MiB message is not shaped for. Two parts of it are state
rather than bytes, though:
- **The facts snapshot** is the controller's composed record of what the mesh is, which the merge check
judges against. It is kept as a document under an OCI tag in the registry, written and read over
HTTP (to-be 45 §9). It is the controller's state, and it lives in another module's store.
- **What the registry holds** is learnt by `docker exec` (above) or over its HTTP API. Nothing
announces it on the bus.
**The forge reached by address (G4).**
- **The builder, the `records` module and the `lab` module** run `git clone` and `git fetch` against
the forge. The lab is given the forge's address as a setting (`${setting:forge}`) rather than a
provision.
- **The node-engine at genesis** uses the forge's admin HTTP API to create its organisation, team and
user.
- **The builder's package registry** is the forge's npm endpoint, given as a binding.
Git objects are bytes, like artifacts. The lab's setting, however, is an address written by hand,
which is what provisions exist to replace.
**The certificate authority over HTTPS.** The proxy and `ca-trust` reach step-ca's ACME directory and
root certificates by `${bound:internal-acme-ca:…}`. ACME is the protocol the proxy speaks, so this
belongs with §2.1. It is listed here because the trust roots are a mesh fact that could be announced
instead.
**The MCP endpoint over loopback HTTP.** claude-code points the agent at the tool runner's loopback
port. The agent speaks MCP over HTTP, and the tool runner is the bridge onto the bus, so this is the
designed entrance (the mesh MCP server) and not a gap.
**Unused HTTP surfaces in the controller (G10).** The controller still listens on two loopback ports:
- An `api` port, whose authentication is a stub that refuses every request.
- A `board` port.
Neither appears in any design as the way to read the mesh. A view built from the bus replaces both.
**Mesh tool code on postgres (G6).**
- **The controller** opens its three databases (`inventory`, `identity`, `licences`). The merge gate
also creates and drops throwaway databases with an administrator credential.
- **Four modules' tool code** opens its own database directly: `mesh-catalog`, `model-usage`,
`claude-licence-manager` and `showcase`.
Each reads only its own database, which the principle allows. The gap is not the connection. It is
that state kept this way is invisible to anything else except through a verb (§3).
`mesh-store.query` runs one read-only statement against any database the store holds. A future view
could be tempted by it. It is an operator's diagnostic, and it would bypass every module's ownership of
its own data.
## 3. State a view could not watch
A view, in the operator's third principle, reads current state from a bucket, learns that it changed
from an event, and reads history from a stream. The table below says which of the mesh's important
state allows that today.
| state | where it lives | event on change | bucket | otherwise |
|---|---|---|---|---|
| conditions | bus only | raised, changed, cleared | `conditions`, `condition-history` | — |
| hand-acts | bus only | **none** | `hand-acts` | — |
| calls the controller answered | bus only | none | `calls` (2 000 kept, 14 days) | — |
| the lease and epoch | bus and `controller_epoch` | none | `lease` | — |
| deliveries and delivery groups | bus only (mesh-delivery) | `transition`, `group` | `deliveries`, `groups` | — |
| what the operator was told | bus only (messenger) | `refused` | `open`, `sent` | — |
| licence bindings | licence manager's database, mirrored | adopted, refused, failing, usage read | `bindings` | — |
| **plans and walks** | `release_plan` (456 rows) | `plan-moved` | **none** | `plans`, `delivery-walks` verbs |
| **builds and artifacts** | `build` (7 193), `build_gate`, `artifact_collected` (2 312) | `built` (from the build seat), `rolled-back` | **none** | `builds`, `artifacts` verbs |
| **nodes: facts, reports, health** | `node`, `node_report`, `node_health`, `tunnel_peer`, `taken`; the facts snapshot in the registry | `applied` and `refused` per report | **none** | `nodes`, `node`, `status` verbs |
| **assignments, modules, seats** | `assignment` (244), `module` (133), `seat`, `seat_holding`, `port_assignment`, `provision_pin`, `binding` | none; a membership per assignment is kept in the ASSIGNMENTS stream | **none** (ASSIGNMENTS is last-per-subject, but has no event) | `modules`, `seats`, `node` verbs |
| **settings** | `settings` (68), `settings_history` (0 rows) | **none** | **none** | `settings` verb |
| **data items and their readings** | `data_item` (124), `data_reading` (602) | none | none | `data` verb |
| **heals** | `heal` (28) | `healer-acted` | none | `healers` verb |
| **durations** | `duration` (14 617) | none | none | `durations` verb |
| **licences, usage, audit** | licence manager's database: `licence`, `usage`, `audit`, `lease`, `offered` | adopted, failing, usage read | only `bindings` | `licences`, `usage` verbs |
| **credentials and grants** | `bus_user` (76), `secret` (143), `module_secret` (121), `enrolment_token`, `person` | `secret-replaced` only | none | `issue`, `rotate`, `token` verbs |
| **the bus's own health** | the server's monitoring port, on loopback | none | none | the `nats` module's tools |
The most watched state is the open conditions, and it is the one example that is complete. The state
a view of the mesh would show first is what each machine runs, how far a delivery has got, and what is
being built. Each of these can be learnt only by calling a verb again and again. A verb's answer is
also bounded by the server's message limit (§4.4).
**Settings change with no trace.** `settings_history` has 0 rows, and no event is published when a
setting changes. A view cannot show who changed a setting or when, and neither can the controller.
**hand-acts have a bucket but no event.** A view can watch the bucket's keys, but other modules cannot
consume a hand-act through the EVENTS stream the way they consume a condition.
## 4. The bus itself
### 4.1 Accounts, grants and credentials
- **Accounts and users.** The bus has one account (`MESH`), and its users hold passwords. The
controller mints each password and seals it to the recipient. It keeps a bcrypt hash and composes
every user, with explicit publish and subscribe lists and `allow_responses` (one answer, one
minute), into `accounts.conf`.
- **Reload.** The node-engine writes that file into the `nats` module's directory. The module's
entrypoint checks the file's digest every five seconds and sends the server SIGHUP when it changes.
- **What is not configured.** The code has no nkeys, no JWT accounts, no operator and no auth callout.
Design 25 §4 rejected the JWT model on purpose for a single tenant.
- **20 bus accounts were live.** There are three per machine (`node.<machine>`, `<machine>.node-tools`
and `<machine>.build-agent`), plus the controller and the control-node's own module users.
- **The grants are wide.** The tool runner on one machine carries the union of every module assigned
there: up to 170 publish and 599 subscribe grants. What keeps one module's code from acting as
another's on the same machine is the tool runner, not the server.
- **People.** A person's bus account exists in the design (design 25 §7), and the `person` table has
0 rows. The operator reaches the bus through the mesh MCP server, which uses the machine's tool
runner account.
**What a view in a browser would need, and is missing:**
- **A WebSocket listener.** None is configured. The server configuration has `port`, `http`, `tls`,
`jetstream` and `include` and nothing else.
- **A way to admit a person** with a short-lived credential and a grant derived from what that person
may see. The NATS mechanism for this is the auth callout, and it is not configured. Without it, each
browser would need a password in the static file, and the file would be rewritten and reloaded for
each login.
- **A grant shape for a view:** read a set of buckets and subscribe to a set of event subjects. No such
grant is composed today. The nearest are the module state grants of ADR 0201.
### 4.2 One server, one copy
- **No cluster, no leaf nodes, no replicas.** The server runs with none of these. No stream or bucket
sets replicas, so every one has a single copy (the server reports `ha_assets: 0`).
- **The control-node is a single point of failure for every link.** All 16 connections cross the
private network to that one machine, at round trips of 33 to 94 ms from the other machines.
- **What stops when it restarts.** Every tool call, declaration, report and event stops. Design 25
§11 names leaf nodes as "deliberately out of scope; noted so it is not forgotten".
- **Backups.** JetStream state is protected by a nightly snapshot of each stream through the snapshot
API (ADR 0235). The live state between two snapshots has one copy, on one disk.
- **The laptop.** It travels and sleeps behind a VPN client. While it is away from the private network
it has no bus at all, even for tool calls between modules on itself.
### 4.3 Observability
- **No system account.** The bus has none, so nothing hears the server's own events about other
users: slow consumers, permission violations, connections and disconnections. The controller hears
only two JetStream advisories, `CONSUMER.MAX_DELIVERIES` and `CONSUMER.DELETED`.
- **Who reads the monitoring port.** The monitoring port (8222) is published on the control-node's
loopback. Only the `nats` module's tools read it, when asked. Nothing turns what it says into a
condition.
Numbers from the live bus that nothing currently raises:
- **2 slow consumers** since the server started 1 day 14 hours earlier.
- **4 688 JetStream API errors** out of 6.08 million calls.
- **4 407 connections** in those 39 hours, against 16 open. The tool runners' connections were 2 to 39
minutes old when read. *I think* a tool runner reconnects often, because its connection age was far
below the server's uptime; the cause is not yet known. Reading the tool runner's journal for
disconnect reasons would find out.
- **About 5.7 million messages** delivered on the subscription to `$KV.mesh-delivery_deliveries.>` in
those 39 hours. That is about 40 a second, for a bucket of 998 keys. *I think* mesh-delivery rewrites
unchanged deliveries on every pass. Comparing each value's revision with its content would confirm
it. Every view that watches the bucket would receive the same stream.
Two further findings:
- **No dead-letter stream.** Design 25 §3 says a message that exceeds `max-deliver` feeds
`mesh.events.dead`, its own small stream. No such stream exists. The advisory is heard by the
controller and kept nowhere else.
- **Stale consumers.** Four durable consumers, one per machine's claude-code, filter on
`claude-licence-manager` events `licence.rotated` and `licence.switched`. No manifest emits either,
and none of the four has delivered a message. The bindings bucket replaced them (ADR 0201). ADR 0230
leaves deleting a consumer to a person.
### 4.4 The message limit
- **The limit.** `max_payload` is the server default, 1 MiB. No configuration sets it.
- **Issue 314.** Answers above the limit were dropped in the sender (issue 314). Both the controller
and the tool runner now page a large answer. The holder keeps the pages for five minutes, and the
caller asks again with a `Mesh-Page` header.
- **Trimming.** Verbs that grow, such as `conditions`, `status`, the self-check and the registry
listings, are also trimmed.
- **Other ceilings.** A module's state value is capped at 256 KiB and a bucket at 64 MiB. A kept call
is capped at 68 KB.
- **What this means for a view.** A view that reads a bucket per key never meets the limit. A view
that asks a verb for a whole list does.
### 4.5 Retention
- **Buckets keep one value.** Every bucket keeps history 1: the newest value per key, and no earlier
ones.
- **Events last a week.** EVENTS keeps 7 days and at most 10 000 messages per subject. Condition
history keeps 90 days and hand-acts keep 90 days.
- **No longer record.** No stream keeps what happened beyond those windows. A view of "what happened
last month" has the store's tables (builds, plans, durations) and nothing on the bus.
## 5. The gap table
The effort column is a rough size: **S** is a pull request or two, **M** is a design section and a few
pull requests across repositories, and **L** is a decision record, a design and work in several
repositories.
| # | gap | where | evidence | impact | suggested direction | effort |
|---|---|---|---|---|---|---|
| G1 | No way for a browser or a person's client to reach the bus | the `nats` module's server configuration; the controller's account composition | no WebSocket listener; no auth callout; one account of static password users; `person` 0 rows | no view onto the bus can be built; every view today would need a backend that reads verbs | a WebSocket listener behind the proxy; an auth callout served by a mesh module that checks the operator's identity provider and issues a short-lived user with a view-shaped grant | L |
| G2 | Most of the controller's state is not watchable | the controller's `inventory` and `licences` databases | §3: plans, builds, artifacts, nodes, assignments, settings, data, heals, durations, licences have no bucket; settings and assignments have no event | a view must poll verbs; nothing else in the mesh can react to a plan, a node's facts or a setting changing; settings changes leave no trace | for each, a bucket of current state written on change and an event naming what changed, the store remaining the controller's record; conditions are the template | M each, L in total |
| G3 | One bus server, one copy of everything | the `nats` module on the control-node | no cluster, no leaf nodes, replicas 1 everywhere, `ha_assets: 0` | a restart of one machine stops every link in the mesh; the laptop away from the private network has no bus at all | a leaf node per machine, so local traffic and tool calls survive the hub; later a three-node cluster with replicated streams for what must not be lost | L |
| G4 | Mesh state and addresses kept off the bus | the facts snapshot in the registry; the forge address as the lab's setting; the registry's contents | §2.2: the snapshot is an OCI tag over HTTP; `${setting:forge}`; the registry is read by `docker exec` or HTTP | the merge check depends on the registry for the controller's state; a hand-written address does not follow the forge | the facts snapshot in a bucket or JetStream object store; the forge as a provision; the registry announcing what it holds as events. The bytes of artifacts and git stay off the bus as a named exception | S–M |
| G5 | Side doors between modules | `artifact-store-tools`, restic's dumps, the node-engine's genesis, `build-agent` and `supabase` on the container runtime's socket, forticlient's socket, route-adapter's files, 8 media modules' HTTP checks | §2.2 | a module acts on another's container, socket or files with no grant the bus can check and nothing announced; code is copied between modules | each owning module serves the act as a tool (`distribution` lists what it holds, each store module serves its dump, the resolver's verbs over the bus, each download client answers "is this reachable with this key"); the socket kept only where building containers is the module's job | M |
| G6 | State behind a module's own database, seen only through its verbs | `claude-licence-manager`, `model-usage`, `mesh-catalog`, `showcase` | each opens its own postgres; licences, usage and audit have no bucket | allowed by the principle, but invisible to a view | the same pattern as G2, per module: a declared bucket (ADR 0201) for the current state a person would look at | S per module |
| G7 | The bus does not watch itself | the `nats` module; the controller's advisories | no system account; no dead-letter stream; 2 slow consumers, 4 688 API errors, 4 407 connections in 39 h, unexplained; four consumers with nothing to consume | a failing or churning link is noticed only when someone asks | a system-account user for the `nats` module that turns the server's own events into conditions; the dead-letter stream design 25 promises; the reconnect rate and the delivery bucket's write rate investigated | S–M |
| G8 | History is short and in the store | every bucket keeps history 1; EVENTS keeps 7 days | §4.5 | a view of what happened beyond a week reads tables, not the bus | a history stream per kind of change that a view needs (deliveries, plans, builds, hand-acts), with its own retention, declared like a bucket | M |
| G9 | Answers are bounded by one message | the server's default `max_payload` | 1 MiB; paging since issue 314 | a view that asks verbs for lists pages; one that reads buckets does not | let views read buckets, not verbs; keep paging for verbs | S |
| G10 | Unused HTTP surfaces | the controller's `api` and `board` ports | `api` refuses every request; neither is named in a design | dead code that suggests a second way in | remove both once a view exists | S |
## 6. How the approval flow fits
Another effort is designing the approval flow: a person's decision, asked and answered on the bus,
generic over what is being approved. It continues to-be 46 §6 (asks) and §10 (asks that authorise)
under ADR 0234 and ADR 0258. This effort does not duplicate it. It notes only where it meets the
three principles:
- **An open ask is current state.** It belongs in a bucket, keyed by the ask, so a view lists what
waits for the operator and watches it change. The messenger's `open` bucket is the precedent.
- **An answer is an event.** The proof it carries is checked by the controller, never by the view
that collected it (ADR 0258: a click proves only that something at the session chose).
- **A view that lets the operator answer** needs G1's person-shaped credential. That credential lets
the controller tell the operator's own answer from anything else that holds the session.
## 7. What this effort did not read
- **The TypeScript of the tool runner** (`node-tools/src`). The Go part was read.
- **Each media app's own code beyond its provisioner and tools.** The apps themselves are upstream
software and are judged by §2.1.
- **Live traffic per subject**, beyond the subscription counters the server keeps.
@@ -1,81 +0,0 @@
# 036 — The target: a mesh that happens on the bus
This is where the gaps in [01-evidence.md](01-evidence.md) point. It is a picture to test proposals
against, not a design. Each paragraph names the gap it answers.
## Every link between modules is a subject
A module owns its state: its files, its database, its container. It is reached only by its tools, its
seat's verbs, its events and its buckets.
- **No module acts on another's container, socket or files.** A store module serves its own dump. The
registry says what it holds. The resolver's verbs are called over the bus like any other seat's. A
download client answers whether it is reachable with a key (G5).
- **Every act is grantable.** Because every act is a subject, the bus can refuse it, and the controller
can say who may perform it.
The bus is not for bulk bytes, and two kinds of traffic stay off it by name:
- **Bulk bytes stay where they are.** The bytes of an artifact and of a git repository, and an app
using its database, keep their own protocols.
- **Who reaches them is decided on the bus.** Where to reach them is a provision, and what exists is
announced on the bus (G4).
## Every important state is watchable
For each thing a person would look at, the owner keeps three things on the bus:
1. **A bucket** of current state, one key per thing: a node, an assignment, a plan, a build, a
delivery, a setting, a licence, an open ask.
2. **An event** for each change, naming the thing and what changed. Another module can consume it, and
a view can redraw from it.
3. **A history stream**, where the past matters, with a retention the owner declares (G8).
The store remains the owner's record where it needs one. The bucket is written from it on change, never
instead of it. The open conditions already work this way. Plans, builds, nodes, assignments, settings
and licences come next (G2, G6). A verb is still how something is asked or done. It stops being the
only way to see.
## Views are built from the bus
A view is a web page or a terminal UI that shows part of the mesh. *View* is proposed here as the
glossary word for this. It has no backend of its own:
- **It connects** to the bus over WebSocket, through the proxy, as the person using it.
- **It reads** the buckets it shows, watches them, and subscribes to the events that explain a change.
- **It acts** by calling the same tools and verbs an agent calls. A decision travels as an answer to
an ask, carrying its proof, and the controller checks it (the approval flow, §6 of the evidence).
The person is admitted by an **auth callout** served by a mesh module. That module checks the person's
login with the mesh's identity provider and issues a short-lived bus user. The user's grant is derived
from the view's declaration: these buckets to read, these events to hear, these tools to call. No
password is written into the server's file for a person, and a person's access ends when the credential
expires (G1).
## The bus survives a machine
Every machine runs a **leaf node**:
- **Local traffic stays local.** A module's connection is local, and tool calls between modules on one
machine keep working when the hub is away, as they must on a laptop behind a VPN client.
- **What must not be lost is replicated.** The control-node's server is one of three in a cluster, and
the declarations, the control stream and the controller's buckets are kept on three disks rather than
one. The nightly snapshot remains the guard against our own mistakes, not against a disk (G3).
## The bus watches itself
- **Server events become conditions.** The `nats` module holds a system-account user and turns the
server's own events into conditions, like any other probe: a slow consumer, a permission refused, a
client reconnecting in a loop, a consumer that nothing feeds.
- **Exhausted messages are kept.** A message delivered as often as allowed lands in a dead-letter stream
that a view can show (G7).
- **Nothing to rescue by paging.** Answers stay within one message because views read keys, not
whole lists (G9).
## What is not wanted
- **No view with a database of its own** that copies the mesh's state. That copy drifts, which is the
thing the mesh exists to prevent.
- **No HTTP API on the controller beside the bus** (G10).
- **No second signing hierarchy** for its own sake. The callout issues users under the account the mesh
already has, unless a separate account for people turns out to be the simpler grant.
@@ -1,39 +0,0 @@
---
status: active
initiated: 2026-10-08
touches:
- 02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- the catalogue's module manifests
- the media catalogue's module manifests
became: []
---
# 037 — The catalogue's hard-coded preferences
## What is investigated
[ADR 0262](../../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md)
lets a manifest give a **preference** a default, which the mesh-wide settings and a node's settings
override. Before it, a value a person might want to tune per machine could only be written into a
module's file as a literal. This effort lists those literals in both catalogues: the value, where it
is, the key and default it would take as a preference, and why a person would change it.
## Why
The first case was the notifier's font size, which is less than half as tall on a laptop panel of
about 240 DPI as on a desktop monitor of about 110 DPI, while X is told 96 on both. The same is true of
every size, font and gap on the desktop, and of retention counts, timeouts and thresholds in the
services. A list says which modules to convert, in what order, and which values are shared between
modules and should keep one default.
## What it touches
The manifests of both catalogues, and through ADR 0262 the controller's `settings` verb, which lists
every declared preference. Nothing is changed here: each conversion is a change to its module, made on
the operator's yes.
## Documents
- [01 — the survey](01-the-survey.md): every candidate, by module, strongest first, with the modules
that already take settings and those that have nothing to convert.
@@ -1,115 +0,0 @@
# 01 — The survey
Both catalogues were read on 2026-10-08: every manifest's file contents and the sources they are
copied from, the shell code modules contribute to the login session, and the contributions to the
display session. Nothing was changed. Each row is a literal that is a preference in the sense of
[ADR 0262](../../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md):
true of the software, and only tuned per machine or per mesh. The proposed default is always today's
value, so converting a module changes no machine until a value is set.
Values that are the operator's own (names, addresses, accounts, secrets) are left out, as are values the
mesh assigns (`${port:…}`, `${dir:…}`, `${machine:…}`, `${bound:…}`, `${secret:…}`) and structure nobody
tunes. Every proposed key passes ADR 0262's whole-word check.
**Where a literal sits decides what converting it needs.** A row marked *(shell)* is in code a module
contributes to the login shell, and one marked *(contribution)* is in a contribution to the display
session. ADR 0262 fills `${setting:…}` in files, contributions and served facts, but not in shell code,
so the *(shell)* rows need the controller to fill shell code too before they can convert. A row marked
*(compiled)* is in a module's own program and needs the program to read the value from a file first.
## The desktop, strongest first
| Module | Where | Today | Key | Why a person changes it |
|---|---|---|---|---|
| xorg | the X resources file | `Xft.dpi: 96` | `dpi` | A laptop panel of about 240 DPI and a desktop monitor of about 110 DPI want different values; the file's own comment already expects a setting |
| xterm | X resources *(shell)* | `faceSize: 12`, face JetBrainsMono Nerd Font, `saveLines: 10000` | `font-size`, `font`, `scrollback-lines` | Terminal text size per screen; the face and history are taste |
| i3 | its configuration | `font pango:JetBrainsMono Nerd Font 11` | `font-size` | Title text size follows the screen's density |
| i3 | its configuration | `gaps inner 0`, `gaps outer 0`, `default_border pixel 1`, resize step 10 px | `inner-gap`, `outer-gap`, `border-width`, `resize-step` | Taste and density; a 1-pixel border is hard to see on a dense panel |
| i3 | its configuration | focused colour `#de5200` | `accent-colour` | Taste; the same colour is in the bar and the launcher (see below) |
| i3status-rust | the display-session contribution *(contribution)* | bar font size 11 | `font-size` | The bar's height follows it |
| i3status-rust | the top bar | window title cut at 50 characters | `title-width` | How much title fits depends on the screen's width |
| i3status-rust | the bottom bar | disk warning at 850 GB, critical at 920 GB | `disk-warning`, `disk-critical` | Thresholds written for one disk; wrong on a smaller laptop drive |
| i3status-rust | the bottom bar | updates warn at 10, polled every 1800 s; clock format with seconds, every 1 s; container block every 2 s, disk every 20 s | `updates-threshold`, `updates-interval`, `clock-format`, `refresh-interval` | Polling costs battery; the clock is taste |
| rofi | its theme | font size 11, width 600 px, 8 lines, icon 24 px, padding 10 px, message font size 11 | `font-size`, `width`, `lines`, `icon-size`, `padding`, `message-font-size` | The launcher's size per screen |
| rofi | the power menu theme | width 800 px, font size 32 | `power-menu-width`, `power-menu-font-size` | Too large on a small panel. The power menu script also passes 350 px, which overrides 800: one of the two is dead |
| rofi | its configuration | history of 25 | `history-size` | History length |
| screen-lock | the session's X settings *(shell)* | lock after 1800 s; display standby 1800 s, off 3600 s | `lock-after`, `display-standby-after`, `display-off-after` | The clearest laptop and desktop difference |
| screen-lock | its wrapper script | blur 5, ring width 7.0, accent `#ca4a00`, time and date formats | `blur`, `ring-width`, `accent-colour`, `clock-format`, `date-format` | Look, density and locale |
| picom | its configuration | terminal opacity 0.95 focused and 0.75 not; tooltip 0.95; fade step 0.05; corner radius 0 | `terminal-opacity`, `inactive-terminal-opacity`, `tooltip-opacity`, `fade-step`, `corner-radius` | Taste |
| picom | its configuration | `backend = "glx"` with vsync | `backend` | Depends on each machine's graphics driver |
| adwaita | GTK 3 and 4 settings, Qt settings, and the GNOME settings *(shell)* | interface font Inter 11, monospace 11; cursor size 24 (also in X resources and GNOME settings); double-click 400 ms, 3 scroll lines | `font-size`, `cursor-size`, `double-click-interval`, `scroll-lines` | Interface size on a dense panel; the cursor size must change in all four places together |
| fonts | the fontconfig defaults | monospace JetBrainsMono Nerd Font, sans Inter, serif Noto Serif | `monospace-face`, `sans-face`, `serif-face` | Taste; every desktop module names these faces again |
| the laptop's hardware module | touchpad configuration and its script | acceleration 0.15, tapping on, natural scrolling on | `touchpad-speed`, `tap-to-click`, `natural-scrolling` | Pointer feel; the script repeats 0.15 |
| the laptop's hardware module | the power daemon's battery levels | low 15 %, critical 10 %, act at 7 %, action suspend | `battery-low`, `battery-critical`, `battery-action-at`, `battery-action` | Thresholds move as a battery wears |
| the laptop's hardware module | its scripts | brightness step 5; on-screen display for 1500 ms | `brightness-step`, `notification-timeout` | Feel |
| feh | the wallpaper command | `--bg-fill` | `wallpaper-mode` | Fill, scale or centre is taste |
| zsh | its rc file | `HISTSIZE` and `SAVEHIST` 100000 | `history-size` | History length |
| lemurs | its configuration | background black, border white | `background-colour` | Low value; only the start of the file was read |
## Services
| Module | Where | Today | Key | Why a person changes it |
|---|---|---|---|---|
| restic | its program *(compiled)* | keep 14 daily, 8 weekly, 6 monthly | `keep-daily`, `keep-weekly`, `keep-monthly` | Retention is the clearest service preference, and today it cannot be changed without a new build |
| logrotate | its configuration | weekly, keep 4; the login record monthly, keep 1 | `rotate-period`, `rotate-count`, `wtmp-rotate-count` | Retention per machine and disk |
| docker | the prune unit and timer | prune what is older than 168 h, weekly | `prune-age`, `prune-schedule` | How long images and build cache stay |
| docker | the daemon's configuration | no log rotation at all | `log-size`, `log-files` | **A new value, not a conversion:** container logs grow without bound today. A default such as 10 MB and 3 files changes every machine, so it is a decision of its own |
| memory-pressure | the zram generator | `min(ram / 2, 16384)` | `zram-size` | RAM and workload |
| memory-pressure | the out-of-memory daemon and the session's limit | swap used 90 %, pressure 60 % for 20 s; the session 80 % | `swap-used-limit`, `pressure-limit`, `pressure-duration`, `session-pressure-limit` | How early processes are killed |
| time-sync | its servers | a regional pool | `pool-region` | The region is the mesh's |
| pacman | its configuration and the mirror list's options | 5 parallel downloads; a country list, the latest 20, sorted by rate | `parallel-downloads`, `mirror-countries`, `mirror-count` | Bandwidth and region |
| fail2ban | its jails | ban 1 day after 3 failures in 1 day; repeat offenders 4 weeks after 2 in 2 weeks | `ban-time`, `find-time`, `max-retries`, `repeat-ban-time`, `repeat-find-time`, `repeat-max-retries` | Strictness |
| sshd | its configuration | port 22 | `listen-port` | A port people move. The filter and the jail name the port too, and must follow it |
| dnsmasq | its configuration | two public resolvers | `upstream-resolvers` | The choice of resolver |
| icecast | its configuration | client timeout 30 s, source 10 s, burst 65535, queue 524288, log level 3, log size 10000 | `client-timeout`, `source-timeout`, `burst-size`, `log-size` | Streaming tuning |
| mailu | its environment file | message size 50 MB, 200 messages a day, sign-in rate limits, fetch every 600 s, log level | `message-size-limit`, `message-rate-limit`, `sign-in-rate-limit`, `fetch-delay`, `log-level` | Real limits a person sets |
| matrix | the homeserver's configuration | request size 20 MB | `upload-size-limit` | The upload cap |
| supabase | three files | a sign-in lifetime of 3600 s, written three times | `session-lifetime` | One value in three places, which a setting keeps equal |
| supabase | the functions' main | 150 MB, 60 s | `function-memory-limit`, `function-time-limit` | Function limits |
| power | its stop unit and script | 120 s, 30 s | (weak) | Bounds on shutdown work, rarely changed |
## The media catalogue
| Module | Where | Today | Key | Why |
|---|---|---|---|---|
| qbittorrent | its web login script | ban for 60 s | `ban-duration` | How long a failed login is locked out |
| tautulli | its start script | wait 60 s for the media server | `media-server-wait` | Low value |
The other media modules carry only identities from settings, key-provisioning scripts and mergeable
files. Nothing to convert.
## Already taking settings
- **`${setting:…}`:** the notifier (font size and width, the first preferences), power, hostname, xdg,
keycloak, mailu, lab, and every media module.
- **A mergeable file:** baserow, claude-code, the licence manager, confluence, gitea, gitlab, grafana,
home-assistant, icecast, influxdb, jira, keycloak, letta, mailu, matrix, mesh-watcher, messenger,
nextcloud, nodered, records, route-adapter, searxng, step-ca, supabase, unifi; in the media
catalogue bazarr, bookshelf, jackett, kometa, lidarr, nzbget, ombi, qbittorrent, radarr, sonarr and
tautulli. A mergeable file's content already is its defaults; ADR 0164's declaration would list its
keys.
## Nothing to convert
avahi, blueman, bluetooth, build-agent, ca-trust, clipmenu (its 1-second timeout is a safety bound),
cups, dbus, de-spiegel, disk-load, distribution, dmenu, gnome-keyring, hello-web, invoicing, letta,
local-model-consumer, localization (the language and keymap are the operator's, not a preference),
mesh-catalog, minio, model-usage, mongodb, mosquitto, mssql, n8n, nats, networkmanager, nftables,
node-env, only-office, redis, route-proxy, ssh-client, sudo, systemd, systemd-networkd,
systemd-resolved, triggerhappy, umami, xclip. A container's internal port is structure, not a
preference.
## What follows from it
- **Shared values want one default each.** The accent colour is `#de5200` in the window manager, the
bar and the launcher, and `#ca4a00` in the lock screen. The interface font size is 11 in the window
manager, the bar, the launcher and the GTK, Qt and GNOME settings, and 12 in the terminal. As one
setting per module they would drift. Either each module declares the same default and a person sets
them together, or the mesh grows a preference several modules read. That is a decision, not a
conversion.
- **Three prerequisites in the controller and the modules:** filling `${setting:…}` in shell code, for
the *(shell)* rows; the restic program reading its retention from a file; and, for the container
runtime's log rotation, a decision on the new default.
- **Order:** the density rows first (xorg's DPI, then the font sizes of the window manager, the bar,
the launcher, the terminal and the interface, and the cursor size), because the laptop panel is the
case that started this. Then the lock and display timeouts, then retention and thresholds.
@@ -1,82 +0,0 @@
---
status: graduated
initiated: 2026-10-08
touches:
- 04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md
- 04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md
- 01-RESEARCH/027-the-system-layer-as-modules/02-candidates-and-questions.md
- 03-DESIGN/01-to-be/26-the-seats.md
- 03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md
- 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
- 02-DECISIONS/0051-shared-data-is-the-operators.md
- 02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md
- the catalogue's module manifests
- the controller's seat set
became:
- 03-DESIGN/01-to-be/52-shares-and-mounts.md
- 02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md
---
# 038 — Shares and mounts the mesh manages
## What is investigated
How the mesh shares a directory from one node and mounts it on the others, so that no machine
carries a hand-written export or a hand-written mount line. Three kinds of source are in scope:
1. **A node's share.** The home server shares its storage pool over NFS. A node that wants the files
mounts them.
2. **A device outside the mesh that is not always there.** A gaming device on the home network shares
a folder over SMB, and only while it is switched on. A USB disk is the same case.
3. **What the mesh should show and say** when a mount works, when it does not, and when its source is
simply away.
## Why
On 2026-10-08 the operator found two failed mounts on the workstation, and said two things:
- about the media library: *"This should be handled by the mesh. If a node exposes an NFS share, it
should get configured on all the nodes in their fstab?"*
- about the gaming device's share: *"This mount only works if I plug in my recalbox machine."*
They also set a requirement: **the home server shares its storage pool over NFS**, the workstation's
library mount comes from that share, the export reaches only the mesh's private network, it squashes
root, and the media modules' data (the Plex library is sacred) must not be put at risk.
[Issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
predicted the first failure on 2026-09-30: nothing declares the share's ports, so the packet filter
closes them. This effort measures what happened, and proposes the decision issue 169 asked for.
## What was found
The detail is in [01-evidence.md](01-evidence.md). In short:
1. **The library mount times out because the home server's packet filter drops NFS.** The server
exports the library and its NFS service runs. The mesh's filter accepts only what a module
`listens` on, and no module listens on 2049, 111 or 445. The server's NFS counters read zero I/O
since its last boot six days earlier, so no client has used either share since then.
2. **The gaming device's mount fails because the gaming device is off.** It is an SMB share on the device itself,
not a disk. The fstab line has no `nofail` and no automount, so each boot records a failed unit.
The line also carries the share's password in a world-readable file.
3. **The export is not where a person would look.** The server's `/etc/exports` holds only comments.
The export comes from a property of the ZFS dataset, which writes its own file in `/etc/exports.d`.
The library is a child dataset of the pool, so an export of the pool's root does not show the
library's files unless it crosses into child filesystems.
4. **User ids do not match across nodes.** The library is owned by a service account and group. The
operator's account has the same id (1000) on every node, but on the home server that id belongs to
a different account, which owns the pool's backup repository. NFS with `sec=sys` trusts the client's
id. A plain export of the whole pool would give the workstation's operator account the backup
repository.
5. **No module, and until now no tool, covers any of this.** No module in either catalogue exports or
mounts anything. No mesh tool read a machine's fstab, its mount units or its exports, so this effort
read them through the login shell. A read-only `mounts` module now proposes those tools.
## Where it goes
[02-options.md](02-options.md) weighs the options. The decision is
[ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md),
and the design is [52 — Shares and mounts](../../03-DESIGN/01-to-be/52-shares-and-mounts.md).
On 2026-10-08 the operator chose **one share per folder**, so the backups are never exported, and
**mesh machines only**, over the private network. Which nodes mount a share, and whether the laptop
mounts read-only, is decided at assignment.
@@ -1,134 +0,0 @@
# 038 — Evidence
Measured on 2026-10-08 on the four nodes, read only, through the mesh's tools: `node-service-manager`
(`failed`, `status`, `journal`), `node-uplink.links`, `node-packet-filter.rules`,
`disk-load.disk_filesystems`, `docker.docker_list`, and `node-login-shell.execute` for the files no
tool read (§7). Addresses are given in documentation ranges: the home network (the underlay) as
`192.0.2.0/24`. Node roles: the **home server**, the **workstation**, the **laptop**, the **control-node**.
## 1. The workstation's two failed mounts
`node-service-manager.failed` on the workstation lists two failed system units, both generated from
`/etc/fstab` (their unit file is under `/run/systemd/generator`, and `mesh_declared` is false):
| unit | the fstab line (password removed) | what the journal says |
|---|---|---|
| the library | `<home server>:<library> <same path> nfs defaults,_netdev 0 0` | `Mounting timed out. Terminating.` 90 s after boot, at every boot since 2026-10-04; the last successful mount was at the boot of 2026-08-23 |
| the gaming device | `//<device>/Share <mountpoint> cifs username=root,password=…,iocharset=utf8,sec=ntlmssp 0 0` | `mount error(113): could not connect to <device>` at every boot |
Neither line has `nofail` or `x-systemd.automount`. Both name the source by its underlay address.
`remote-fs.target` is inactive on the workstation, because both of its members failed.
## 2. Why the library times out
- The home server exports the library, and its NFS and Samba services run. `nfs-server` and `smb`
are active and enabled, and ports 2049, 111 and 445 are listening on every address.
- **The mesh's packet filter drops them.** The mesh's table on the home server has `policy drop` on
its input chain for the underlay link and the private network link. It accepts a port only where a
module `listens` on it ([ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)).
No rule names 2049, 111 or 445.
- From the workstation, a TCP connection to the home server's port 2049 fails, and one to port 22 on
the same address succeeds.
- The home server's NFS counters (`/proc/net/rpc/nfsd`) read `io 0 0` since its boot on 2026-10-04.
No established connection to 2049 or 445 exists. **Nobody has used either share since that boot.**
This is the failure [issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
predicted: the shares close silently once the filter is the sum of what modules listen on.
## 3. What the home server exports, and from where
- `/etc/exports` holds only the distribution's comments; it was last written on 2026-09-20.
- The export comes from ZFS. The library dataset has `sharenfs=rw=@192.0.2.0/24` and `sharesmb=on`.
ZFS writes `/etc/exports.d/zfs.exports` itself, marked *do not edit*. The kernel's export table
(`/var/lib/nfs/etab`) holds one entry: the library, to the underlay range, `rw,sync,root_squash,
no_all_squash,secure,no_subtree_check,sec=sys`.
- The Samba configuration has one share, `media`, on the library, `valid users = media`,
`force user = media`, writable.
- **The pool is two filesystems.** The pool is mounted at its root, and the library is a child dataset
mounted under it. An NFS export of the pool's root does not show the library's files unless the
export crosses into child filesystems (`crossmnt`); without it, a client sees an empty directory
where the library should be.
## 4. What lives on the pool, and who uses it
The pool's root holds five directories:
| directory | owner | what it is |
|---|---|---|
| the library (child dataset, about 90 TiB used) | the media account and group, `0775` | movies, series, anime, music, audiobooks and more |
| downloads | the media account and group | the download clients' and the library managers' working area |
| archive | root | the operator's archive |
| a second Plex folder | the operator's account on the home server | an older library folder |
| the backup repository | the operator's account on the home server, `0700` | the restic repository the backup seat writes |
Plex's own configuration and database live on a separate disk, not on the pool.
Eight running containers bind paths on the pool, all of them as plain bind mounts of local paths:
Plex (the library's folders, all read-only), the library managers for series, movies, music and
subtitles, two download clients, and one automation app. **Exporting the pool over NFS changes none of
these paths and none of their owners.** An export adds a way in; it does not move or chown anything.
The only way an export endangers this data is by what it lets a client do (§5).
## 5. User and group ids across the nodes
| node | id 1000 | the media account | the media group |
|---|---|---|---|
| home server | a separate account, which owns the backup repository and the older Plex folder | exists, owns the library | exists |
| workstation | the operator | exists, same ids as on the server | exists; the operator is a member |
| laptop | the operator | absent | absent |
| control-node | the operator | absent | absent |
The library managers on the home server run as the media account and group, set by their `puid` and
`pgid` settings.
With `sec=sys`, the server believes the id a client sends; `root_squash` changes only root. So:
- **A client's operator account (1000) is the home server's other account (1000).** An export of the
pool's root would let it read and write the backup repository and the older Plex folder.
- **A file written by the laptop's operator lands owned by 1000**, not by the media account, and the
library managers may then fail to rename or delete it.
- A device on the underlay that is not a node can claim any id. That is a reason to export to the
private network only, where every address is a node.
## 6. The gaming device's share
- The gaming device is a separate machine on the underlay, a retro-gaming device that shares a folder named
`Share` over SMB. It is not a USB disk. The router's DNS knows its name. It was switched off during
the survey and did not answer a ping.
- The workstation has `cifs-utils`, `avahi` and `nss-mdns`, and its `hosts:` line contains
`mdns4_minimal`. **The packet filter drops inbound mDNS** on every node, as the `avahi` module's
README already records. So discovery by mDNS cannot see the gaming device arrive today. Waiting for it
would first need a local-link reach for `listens`, which is the question of
[issue 154](../../04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md).
- The share's password is written in `/etc/fstab`, which is mode `0644`. Every account on the
workstation can read it.
## 7. What the catalogue and the tools cover
- **No module exports or mounts.** No manifest among the 117 modules of the core catalogue and the 12
of the media catalogue writes an export, a Samba share, a mount unit or an fstab line.
- **`disk-load.disk_filesystems`** lists NFS and CIFS filesystems that are mounted now, with their space.
It cannot see a mount that failed, an fstab line, a mount unit or an export.
- **`node-service-manager`** shows a mount unit's state and journal, but not what it mounts or with
which options.
- **No tool read a machine's fstab, its mount and automount units with their sources, or its NFS and
SMB exports.** This effort read them with `node-login-shell.execute`, which runs a shell command as
the operator account. That is a work-around for a missing tool, so a read-only `mounts` module with
two tools, `mounts_list` and `mounts_exports`, was opened in the catalogue alongside this effort. It
redacts passwords in mount options and never touches a network mount point, so a hung server cannot
hang the tool.
## 8. Prior work
- **Issue 169** (open, the operator's proposal of 2026-09-30): two seats, `nfs-share` and `smb-share`,
because the two protocols share an intent and not a contract; a consumer module that writes a
systemd `.mount` unit from its binding; the modules that use the files never learn about NFS; the
data-loss case of a writer starting before its mount; and identity crossing the wire.
- **Research 027**, option M2: each client module writes its own systemd mount and automount unit, and
`/etc/fstab` stays the machine's.
- **Research 026**, the tools list: `exports`, `shares`, `clients`, `reload` on the server;
`mounts`, `mount`, `unmount`, `test` on the client.
- **ADR 0092**: a credential for something outside the mesh is an operator-delivered secret, accepted
into the vault (`secret accept`) and never replaced by a made one.
- **`${machine:mesh-range}`** already exists as a placeholder in two manifests, so an export line can
name the private network's range without naming a node.
@@ -1,146 +0,0 @@
# 038 — Options
Each question below lists the options weighed and the one recommended. The recommendations together
became [ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md).
## Q1. Who writes the export
| option | for | against |
|---|---|---|
| A. Keep the ZFS `sharenfs` property, and have a module declare only the ports | nothing new on disk | the export lives in a dataset property, beside the operator's data; a module would be setting properties on data the mesh does not own (ADR 0051); the options are one string with no room for squashing |
| B. A module owns `/etc/exports` whole | one file, one owner | the file is the machine's; a second exporter on the same node would collide |
| **C. A module, `nfs-server`, writes its own file in `/etc/exports.d`, and the ZFS property is turned off when the module's export is live** | the mesh owns exactly its file; the distribution reads the directory; unassigning removes the file | a migration step: the property must be cleared by an act, not by hand |
**Recommended: C.** The module holds a node seat, `nfs-share`, so one module exports on a node, and it
provides the provision `nfs-share` (issue 169's proposal). Samba is left as found, with its own seat
`smb-share` later; nothing on the mesh uses it, and the gaming device's SMB share is a client case (Q6).
## Q2. What is exported, and to whom
The operator asked for the pool's root. Exporting it plainly has two problems (evidence §3 and §5): the
library is a child filesystem, so the export needs `crossmnt` to show it; and with `sec=sys`, any client
account with id 1000 becomes the server's own 1000, which owns the backup repository.
| option | for | against |
|---|---|---|
| A. The pool's root, `rw,root_squash`, to the underlay range | what the operator literally asked; what exists today (but for the library only) | gives the backup repository to every client's operator account; open to every device on the underlay, which can claim any id |
| B. The pool's root, `rw,crossmnt,all_squash`, every client mapped to the media account, to the private network only | one share, as asked; every file written lands owned by the media account; the `0700` backup repository is unreadable to the media account, so it stays closed | every client writes as one account, so there is no per-person permission on the share; top-level folders other than the library are writable by every client |
| **C. One share per subtree: the library and the downloads, each `rw,all_squash` to the media account, to the private network only; the backup repository and the archive not exported** | the backup repository is not reachable at all; ownership is right; a consumer mounts only what it needs | two shares instead of one; a later folder needs a new share entry (one setting) |
| D. Kerberos (`sec=krb5`) with id mapping | real per-person identity | needs a KDC and keytabs on every node; far beyond the problem |
**Recommended: C, with B as the operator's alternative if one share is wanted.** Both use
`all_squash` with `anonuid` and `anongid` set to the owner of the exported path, read on the server
(`${access:<id>:uid}`, from issue 153's fix), never written as numbers in a manifest. Both export to
`${machine:mesh-range}` only, so every client is a node, reached over the private network. `root_squash`
is implied by `all_squash`. `sync`, `secure`, `no_subtree_check` stay as they are today. The server
serves NFS version 4 only, so only port 2049 is opened, `from: mesh`; 111 and the mountd ports stay
closed.
**What this does not touch:** the local bind mounts of Plex and the library managers (evidence §4),
the datasets' owners and modes, and the Samba share.
## Q3. How a node mounts a share
| option | for | against |
|---|---|---|
| A. The mesh writes lines into `/etc/fstab` | the file people know | one shared file and one ordered list; the node-engine can place a marked block but every line competes with the operator's |
| B. `autofs` with its own maps | mature automounter | a second daemon doing what systemd already does |
| **C. A `.mount` unit and an `.automount` unit per share, written by one module, `mounts`, which holds the node seat `node-mounts`** | a unit is a unit: its state, its journal, its health; an automount never holds the boot; unassigning removes it; no shared file | units have to be named by the escaped mount path, which the module computes |
**Recommended: C.** The `.automount` is enabled under `remote-fs.target`; the `.mount` is not enabled
on its own, so nothing mounts at boot and nothing fails at boot. The mount is made at first access and
released after an idle time. The options carried:
- `x-systemd.automount` semantics through the `.automount` unit, `TimeoutIdleSec` (default 10 minutes);
- a mount timeout, `TimeoutSec` on the `.mount` (default 30 s for a node's share, 10 s for an
occasional one), instead of systemd's 90 s;
- `_netdev` and an order after the private network's link is up;
- for NFS, `vers=4.2,hard,timeo=150,retrans=3` on a read-write share (no silent write loss) and
`soft` allowed for a read-only one, as a setting.
An automount also closes issue 169's data-loss case: the mount point is always an autofs mount, so a
process that writes there either triggers the mount or gets an error. It never writes into the empty
directory underneath.
## Q4. Which nodes get a share
The operator suggested every node. Weighed:
| option | for | against |
|---|---|---|
| A. Every node mounts every share | no assignment to make | the control-node is the stably reachable machine the controller runs on, and gains a writable path into the library for no use; the laptop, behind a corporate VPN client, would try a mount whenever a file manager looks at the path |
| B. A node mounts a share only when a module on it requires it | precise | a person who wants the files in their file manager has no module that requires them |
| **C. A node mounts what its `mounts` assignment lists; the list may be set once at the mesh-wide layer** | opt-in per node, and "every node" is one setting at the mesh-wide layer, with a node's own layer to leave a share out | two layers to read when asking why a node has a share; `mounts_list` says which layer gave it |
**Recommended: C, assigned to the workstation and the laptop, not to the control-node.** A module that needs
the files (a player, an indexer) requires `nfs-share` as well; the controller then adds the share to
that node's `mounts` unit set, so B is the special case of C, not a second way.
## Q5. Health
| case | finding | condition |
|---|---|---|
| a node's share, automount armed, server answers | healthy | none |
| a node's share, server's 2049 unreachable from this node for 5 minutes | not healthy | `share-unreachable`, a warning: *"The home server's share `library` is not reachable from the workstation since 19:21."* |
| a node's share, the mount failed with an error other than a timeout | not healthy | the same condition, with the error |
| an occasional source, absent | **absent**, a state, not a finding | none |
| an occasional source, present but the mount fails | not healthy | `share-unreachable` |
| the server: the export table lacks a declared export, or `nfs-server` is down | not healthy | the module's ordinary health condition |
The `mounts` module's code probes each source's port on a timer and keeps each share's state in its
own key-value bucket (ADR 0201): `mounted`, `armed`, `absent`, `unreachable`, with since when. Its
health reads that bucket. Nobody reads its files.
## Q6. Occasional sources
The gaming device is not a node, so it cannot hold a module or provide a provision. Weighed:
| option | for | against |
|---|---|---|
| A. A fstab line with `nofail,x-systemd.automount` | small | a hand-written line again, and the password stays in the file |
| **B. An external source in the `mounts` assignment's settings: protocol, name, share, mount point, presence `occasional`, and the name of a credential** | the same unit pair and tools as a node's share; the password goes to the vault | a share with no provider, so no binding: the module checks the name itself |
| C. Wait for the device by mDNS and mount when it appears | mounts before anyone asks | the packet filter drops mDNS today (evidence §6); depends on issue 154 |
**Recommended: B now, C when issue 154 gives `listens` a local-link reach.** With an automount, nothing
has to happen when the gaming device appears: the next access mounts it. The probe only keeps the state
honest, so `mounts_list` says *"the share `retro-games` is not present since 18:02"*. A USB disk is the same kind of source,
with presence taken from its device unit (`BindsTo=` on the disk's by-uuid device) instead of a probe.
**The credential:** the share's password is an operator-delivered secret, accepted into the vault with
`secret accept` (ADR 0092), and reaches the node as a file the node-engine unseals (ADR 0086). The unit
names it as `credentials=<that file>`. Nothing in a repository or in `/etc/fstab` holds it.
## Q7. Taking over the existing fstab lines
The two lines are the operator's. The node-engine's own `.mount` unit in `/etc/systemd/system` already
takes precedence over the generated unit of the same name, but the line would still be read by every
other tool and person.
**Recommended:** the `mounts` module gets an act, `adopt`, that for one mount point:
1. checks a unit pair the module placed is armed for that mount point, and refuses if not;
2. comments out the fstab line, with a marker naming the module and the date, and keeps a copy of the
file beside it;
3. reloads the service manager and says what it did.
It runs only on a person's word. The library line is adopted after the home server's share works over
the private network; the gaming device's after its credential is accepted.
## Q8. Over the bus
Every exchange between the modules is over NATS (research 036):
- the controller wires provider and consumer through the provision; the binding carries `at` (the
server's private-network name), `path` and `options`;
- `nfs-server` keeps its exports and the clients it sees in its bucket, and answers `test` (does it
export this path to this node) by request and reply;
- `mounts` keeps each share's state in its bucket; a view or the controller watches it;
- no module reads another's files (ADR 0260).
## The operator's answers (2026-10-08)
1. **The pool's root, or one share per folder?** One share per folder (Q2 option C), so the backup
repository is never exported.
2. **Underlay clients?** Mesh machines only, over the private network.
3. **Every node, or the nodes that ask? Read-write from the laptop?** Decided at assignment, which is the
operator's; the recommendation (Q4 option C; the laptop read-only) stands in the design.
@@ -1,109 +0,0 @@
---
status: active
initiated: 2026-10-08
touches: []
became: []
---
# 039 — A tool or verb that needs a person's approval
A note to design from later, not a design.
## What is investigated
Whether a module's tool or a seat's verb can declare, in its manifest, that **calling it needs a
person's approval**, and the mesh then refuses the call without one.
The operator's idea, 2026-10-08: *"I think we need to be able to mark tools as 'requires human
approval'."*
The shape, as first seen:
- **The declaration is the tool's own.** The manifest marks a tool or verb as needing approval, beside
the rest of what it declares about itself.
- **The mesh enforces it, not the MCP client.** The tool runner, or the controller for a seat verb,
refuses the call unless it carries a **warrant**: the router's record that a person chose one option
of one ask, from the flow ADR 0259 sets out (hq pull request 211, not yet merged). An agent may ask;
it cannot act alone. A client-side prompt is no boundary, because any tool runner may call any tool
on any node (ADR 0175).
- **The ask carries** what is to be done, why, the exact arguments, and who asks. Its options are
Approve and Decline, Approve at the `approve` **assurance level**. On a warrant for Approve the call
goes ahead **once, with those arguments, before the warrant's expiry**; anything else is refused.
- **Where it is answered** until a channel that declares a verified sender (Telegram) is live: at the
operator's desk, or at the controller's terminal. Open: the desk answers only `acknowledge` options
([ADR 0258](../../02-DECISIONS/0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)),
so the desk alone cannot approve.
## Why now
On 2026-10-08 the only thing between an agent and acts like a snapshot of the media library, a
whole-mesh push or the bus's upgrade was the agent asking in chat before it called the tool. Nothing in
the mesh would have refused the call. The same day, several holes were found in exactly this layer:
an agent on the control-node could become root without a person, and through the login shell's tool an
agent on any node could run a command there as an account that can (ADR 0259 §8 as revised, and
ADR 0266, hq pull request 225, not yet merged). Once agents run without root, **the tools are the
boundary**, and a tool that can do lasting harm needs a person behind each call.
## The first case: the login shell's `execute`
The operator decided on 2026-10-08 that the login shell seat's verb `execute` is the first tool to
need approval, on every node, once the mark exists. It runs any command it is given as the node's
operator account, and that account has passwordless sudo, so one call is root on that node. Until the
mark exists, the verb is removed on the control-node.
The calls it carried (about 300 across the four nodes, counted on 2026-10-08): about a third ran
something with sudo; most of the rest were reads, such as reading or searching a file, listing
containers, asking a unit's state, listing sockets, or looking up a user or a name.
So **narrow read tools must replace the common reads first**, each a tool of the module that owns the
area. Otherwise nearly every approval asked is for a read, and the operator learns to approve without
reading, which makes the approval worth nothing.
## Candidates seen on 2026-10-08
| Tool or verb | Why a person |
|---|---|
| the login shell's `execute` | decided: see above |
| the new zfs module's `zfs_snapshot` | a snapshot of the media library holds its space; on a scratch dataset it does not matter |
| `mesh-controller.push` | sends builds to nodes outside a walk |
| the bus's upgrade, `mesh-controller.bus upgrade` | already a step a person starts ([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) rule 6); today "a person" is whoever calls it |
| `mesh-delivery.release` | the glossary defines it as a person's word, and nothing checks that a person said it |
| the controller's `plans` go and stop | starts or halts a walk |
| a secret's `rotate` | can cut off every consumer of a credential |
| the places and accesses settings | being made terminal-only; approval is the other way |
| the media library's share and mounts (`nfs-server`, `mounts`) assignment | the library is the operator's sacred data |
## What it bears on
- **ADR 0259** (pull request 211): asks, the router, warrants and assurance levels. This effort adds a
new kind of asker: the tool runner or the controller asking on behalf of a call. Who the asker is
matters, because a warrant is issued to one asker alone.
- **ADR 0266** (pull request 225): agents without root. It makes the tool layer the boundary this
effort guards.
- **The `record` upgrade policy** (ADR 0236): "a person's push" is today only a convention.
- **The held delivery's release**: the same.
- **Issue 336** (pull request 228, a hold nobody is told about): an approval waiting with nobody told is
the same fault, so a waiting approval must be said, as a condition or on a channel.
- **The hand-act log** (ADR 0234 §8): a call made on a warrant is recorded with the warrant.
## Open questions
1. **Per tool or per argument.** A snapshot of the library needs a person; one of a scratch dataset
does not. Is the mark on the tool, on an argument's value, or decided by the tool from its arguments?
2. **Who may approve.** Only the operator role exists today. Is that enough for every tool?
3. **Replay and expiry.** One warrant, one call: how is "these exact arguments" bound to the warrant,
and how long does a warrant stay good?
4. **A call waiting for approval.** How is it shown: a condition, the asks list, the call's own answer?
5. **The MCP client while it waits.** Does the call block until answered, or return "asked, waiting"
with a handle the agent calls again?
6. **The answer before a verified channel exists.** The desk is `acknowledge` only; is the controller's
terminal the place, and how does it prove a person there?
7. **Per call, or per session or time window.** For a tool called as often as `execute`, does each
call need its own approval, or may one approval cover one agent session, or a stated span of time?
A wider approval is less noise and more reach for whoever holds it.
8. **Audit.** Is a warranted call a hand-act, and where is a declined or expired one recorded?
## How it will be checked
Measured so far: only the login shell's calls, above. The next evidence to gather: how many tools and verbs in the catalogue act on
data or on other nodes, and of those, how many are guarded by anything but the caller's restraint.
@@ -1,74 +0,0 @@
---
status: graduated
initiated: 2026-10-09
touches:
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 03-DESIGN/01-to-be/40-building-the-operators-agent-and-its-licence-manager.md
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md
- 04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md
became:
- 02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 040 — The agent's home is the module's, and the operator's rules and lessons are registered through it
## What
Three things about the coding agent's own directory under a person's home (`~/.claude`), investigated
together because the operator raised them as one on 2026-10-09:
1. **Who owns the configuration files in that directory.** [ADR 0216](../../02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
gave the agent module a home scope that "owns only what it placed"; everything else there is held as
found ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
The operator's direction reverses that for the configuration: *"the home-scope does own ~/.claude, so
it should be completely empty and manual edits should be forbidden."*
2. **Whose the rules in that directory are.** The rule files a person writes there today are the
person's own, on one machine. The operator's direction: *"Rules in my home directory are my 'personal
rules' which should also end up under control of the claude-code module and all other changes to
~/.claude should go through the claude-code module's mcp tools."*
3. **How an agent learns from a correction.** The operator's direction: *"register the decision, but make
sure it's clear and that you can/should be pro-active about it. For example, if I'm frustrated, try to
see why and try to prevent it in the future by warning yourself about stuff not to do to frustrate me
— kind of like a self-learning process."*
## Why
The home is the one place the agent's configuration still drifts. [01](01-what-the-home-holds-and-the-options.md)
measured it on 2026-10-09 across the four nodes: a skill to-be 36 retired in its first version is still on
all four; a person's instruction file is on three; the operator's two hand-written rules are on one; and
nothing in the mesh places, watches or raises anything about any of it. The corrections the operator has
given the agent live in the agent's own memory for one repository on one machine, 45 notes of which at least
9 are a correction of a way of working, shown to nobody and reaching no other node.
[Issue 340](../../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)
closed the tools' route into the operator's home for the right reason: a tool cannot tell the operator from
an agent, so an agent could plant something every session obeys. Whatever owns the home must keep that
protection. The two routes the mesh now has for the operator's own word, the controller's terminal
([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))
and a warrant on an ask ([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)),
make it possible to write into the operator's home without trusting a tool's caller.
The record was searched first: `records_search` for "personal rules lessons frustration self-learning" and
for "hand edit home .claude drift condition" found nothing on 2026-10-09.
## What it touches
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)
§1 and §8): what under the home is the module's, and the scopes.
- **ADR 0216 §5 and §6** (the home scope owns only what it placed; what it did not place is reported) and
**ADR 0243** (removal on the person's word): the mechanism changes, the decisions stand.
- **ADR 0182**: the class table holds; what changes is which class the configuration files are in.
- **ADR 0245**: the guard gains a refusal, and the session gains a reminder.
- **ADR 0259**: the module becomes an asker, for the operator's yes.
- **Issue 340**: the protection stands; the terminal and the warrant are the two ways in.
## Documents
- [01 — What the home holds, and the options](01-what-the-home-holds-and-the-options.md): the measurement
on four nodes, the agent's own memory as it stands, and the options weighed.
@@ -1,130 +0,0 @@
# 040 — What the home holds, and the options
## 1. The agent's directory on the laptop, 2026-10-09
Read by name on the laptop's own disk, contents not read. The directory holds 33 entries.
**Configuration, written by a person or by the program on a person's choice:**
| path | what | how it got there |
|---|---|---|
| `rules/` | 2 rule files (1779 and 755 bytes): how text meant to be pasted elsewhere is handed over; how git is used in a second forge's repositories | written by hand |
| `skills/` | 5 skills of the operator's own, for the operator's other work; and `skills/synced/`, 2 folders the vendor's own sync placed | by hand; by the program |
| `commands/` | empty | — |
| `settings.json` | 6 keys: the attribution, auto mode, feedback drafts, the model, the spinner tips, the model switch on a flag | by hand, and by the program on a `/model` or `/config` choice |
| `settings.local.json` | 1 key, `permissions`: the rules the program writes when a person answers "always allow" in a session | by the program, on a person's answer |
| `plugins/` | the program's record of its marketplaces (the vendor's and the mesh's `nox-mesh`) and installed plugins, and its cache | by the program |
| no `CLAUDE.md`, no `agents/`, no `hooks/` | the instruction file was removed on 2026-10-07 through the module's removal tool, with a copy kept (ADR 0243) | — |
**Working state, written by the program for itself** (24 entries): the credentials file (the module's own
code writes it, ADR 0183), `history.jsonl` (5 MB), `projects/` (30 directories, 1.5 GB, holding every
project's sessions and the agent's own memory), `sessions/`, `session-env/`, `shell-snapshots/`,
`file-history/`, `debug/`, `cache/`, `paste-cache/`, `telemetry/`, `stats-cache.json`, the daemon's lock,
log and status files, `state/`, `ide/`, `jobs/`, `feedback/`, `backups/`, `worktrees/`, and a few markers.
**The managed directory under `/etc`**, by contrast, holds exactly what the module renders: the instruction
file, the managed settings, the managed tool-server file, and the marketplace with the `nox-mesh` plugin.
## 2. The homes on four nodes, 2026-10-09
Read through each node's `claude_code_config_status` (the module's own status tool), the field listing the
home's own items beside what the mesh placed:
| node | home items the mesh did not place | placed by the mesh at home |
|---|---|---|
| the laptop | 2 rule files, 5 skills | none |
| the workstation | an instruction file, a `cleanup` skill, a subagent (a module reviewer) | none |
| the home server | an instruction file, a `cleanup` skill | none |
| the control-node | an instruction file, a `cleanup` skill | none |
Three things follow:
- **The retired skill is still everywhere.** To-be 36 §1 said on 2026-10-02 that the `cleanup` skill
becomes "nothing; it named the predecessor's forge". It is on all four nodes on 2026-10-09. Nothing
owns it, so nothing removed it.
- **The operator's rules exist on one node.** A rule about how the operator wants text handed over is as
true on the workstation as on the laptop. It is on the laptop alone, because the only way to put it
anywhere is to write the file there.
- **Nothing is raised about any of it.** The status tool lists these items when asked. No condition says
the laptop's rules are missing elsewhere, or that a skill the design retired is still loaded in every
session on four machines. The three mesh-wide instruction sections and the `nox-mesh` plugin (holding
the guard alone) are the only things the mesh renders for a session, and both are in the managed
directory, not the home.
## 3. The agent's own memory, 2026-10-09
The agent keeps notes for itself in the program's memory directory under `projects/` for one repository
on the laptop: 45 notes on 2026-10-09. At least 9 of them are a correction of a way of working that the
operator gave in a session, for example: finish a module before pushing it; use the mesh's tools, not
ssh; read the merge gate's verdict before merging; what is done is what the operator sees working;
no work-arounds when a tool is missing; a language choice the operator insists on. Each was learned once,
after the operator said it, sometimes with irritation.
Those notes:
- are on one machine, for one repository: a session on the workstation, or in another repository on the
laptop, starts without them;
- are written by the agent for itself, in its own words, and shown to nobody: the operator cannot see
what the agent taught itself, or correct it;
- are not read before a tool runs: a note saying "read the merge gate first" is read at session start,
if the agent opens it, and nothing says it again at the moment a merge is about to be called.
The mesh keeps one related record: the guard's record of every refusal and override
([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md) §5),
which is how a habit that found a way round is seen. It records what the guard refused, not what the
operator corrected.
## 4. What the vendor loads from the home
As documented by the vendor and confirmed on the laptop on 2026-10-09 (a session's own context names the
two rule files as "the user's private global instructions for all projects"): the home's `CLAUDE.md` and
every file under `rules/` are read into every session the account starts, in every project; `skills/`,
`commands/` and `agents/` are offered under their own names; `settings.json` is the account's settings,
below the managed settings; hooks come from `settings.json` and from a plugin. None of it is read for
another account, and none of it reaches another machine.
## 5. The options
### Who owns the configuration under the home
1. **As today: the module owns what it placed, the rest is found** (ADR 0216 §5, ADR 0182). Rejected by
the operator's direction, and by §2: a rule on one node reaches no other, a retired skill stays on
four, and nothing is raised. There is a second reason: the guard refuses an agent that reaches a
machine by ssh, and refuses nothing that writes `~/.claude/rules/x.md`. On a node where the agent
runs as the operator, that is a rule planted in every later session, by hand, with no record.
2. **The module owns the whole directory and regenerates it.** Rejected in ADR 0182 (option 1), and still:
the working state is the program's, and a render that touched `projects/` would erase a season of
memory.
3. **The module owns every configuration file by name, excludes the program's working state by name,
renders the configuration from registrations, and reports a hand edit as a condition.** Chosen.
### Whose the rules are
1. **Per node** (the home scope as ADR 0216 defines it: one account's directory on one machine). Rejected
as the only scope: the operator is one person on four nodes, and §2 shows what one-node rules give.
2. **Mesh-wide instruction sections**, which already reach every node. Rejected as the place for them:
those are the mesh's word to every session, written at the controller's terminal, about the mesh.
A personal rule is the operator's word about the operator: how to hand over text, which tools their
other employer's repositories need. It belongs to the person and follows the person's agent, into the
home, under the person's own names.
3. **A new kind, the personal rule, registered for the operator and rendered into the agent's home on
every node.** Chosen. The same scope serves the operator's own skills, commands, subagents and settings.
### How a lesson becomes a rule
1. **The agent writes the rule itself.** Rejected: an agent that writes its own rules can loosen them, the
operator never sees what it taught itself, and issue 340's reasoning holds — text that every session
reads is written on the operator's word, never on an agent's.
2. **Lessons stay in the agent's memory.** Rejected by §3.
3. **A lessons register in the module. An agent proposes a lesson; the operator says yes or no; a yes
makes it a personal rule, rendered everywhere; the agent is reminded of the rules that bear on what it
is about to do.** Chosen.
### How the operator's yes reaches the operator's home
1. **Trust the tool's caller.** Rejected in issue 340.
2. **The controller's terminal only.** Safe; kept as one of two ways. It makes a yes in a session a trip
to the control-node, which is what the operator asked to avoid.
3. **A warrant on an authorising ask** (ADR 0259): the module asks the operator, with the exact text
bound in the ask, and acts on the warrant alone. Chosen beside 2. The approve option needs a channel
that proves its sender; until one answers, the terminal is the way.
@@ -1,68 +0,0 @@
---
status: active
initiated: 2026-10-10
touches:
- 02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md
- 02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md
- 02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md
- 01-RESEARCH/039-a-tool-that-needs-a-persons-approval/00-overview.md
became: []
---
# 041 — The agent's session runs confined, instead of being proven by a disk-wide search
## What
Whether the coding agent's session on a node with an **agent account** should be **confined when it
starts**, so that what it cannot do is enforced by the kernel, instead of the node-engine proving the
account harmless by searching every local filesystem for setuid programs. Three parts, from the
operator's idea of 2026-10-10:
1. **No new privileges.** The session and everything it starts run with the kernel's `no_new_privs` flag,
so a setuid or setgid program, packaged or not, and a program with file capabilities, grant it nothing.
2. **A confined view of the files.** The session sees `/usr` read-only, its own home, a private `/tmp` and
the few system files a program needs to run, and nothing else.
3. **Everything else through the mesh's tools.** What an agent reads outside that view (a file, a
directory, a log) is a tool of a seat, not a path.
And then: what must be **proven** for a node to be root-free under such a start (every way into the
account goes through it), how the node-engine checks that cheaply, and what becomes of the setuid search.
## Why
[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)
§3 item 10 judges the agent account by a search of every local filesystem mounted without `nosuid` for
setuid or setgid root programs no package owns, bounded at 15 minutes. Until a search finishes, the verdict
is *not judged*, and [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)
§8 rule 2 counts a machine without a recent verdict as not root-free, so the router approves nothing proven
there and a channel's `verified-sender` is stripped.
Measured on 2026-10-10 (the controller's `root-free` verb and `node` verb): on the control-node the search
"did not finish within 15m0s", the agent account is *not judged*, and two urgent conditions are open
(`agent-root` and `root-not-free`). Linking the operator's phone channel is blocked by it.
[Issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md) gap 9 had
foreseen it ("on a large data pool the root search may never finish"). A resumable search is the short-term
fix, tracked separately. This effort asks whether the search is the right instrument at all: it answers
"is there a program that could make this account root?", while the question ADR 0259 needs is "can an
agent's session become root?". A confined start answers the second question directly.
## What it touches
- **ADR 0266 §3 and §4**: which ways to root the node-engine judges, and what a root-free verdict rests on.
Under `no_new_privs` item 10 (unpackaged setuid) and the *not judged* file capabilities no longer
apply to the session; new things must be proven instead (§3 of the findings).
- **ADR 0266 §7 and to-be 36 §9**: `claude-agent` becomes the confined start, not only an account switch.
- **ADR 0259 §8**: `root-free` would read the new proof.
- **ADR 0245**: the guard stays as the session's own refusal; the confinement backs it in the kernel.
- **ADR 0144, ADR 0152 and ADR 0266 §7**: the mesh MCP server has no login of its own, so a confined agent
still reaches every tool the operator reaches. Not closed here; recorded as the boundary of this effort.
- **Issue 344** gaps 1, 3, 4 and 9, and **research 039** (a tool that needs a person's approval).
- **A node filesystem seat** that does not exist today.
## Documents
- [01 — The options, what must be proven, and the open questions](01-options-and-open-questions.md)
@@ -1,184 +0,0 @@
# 041 — The options, what must be proven, and the open questions
Sources: [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)
§3, §4, §6 and §7; [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)
§8; [to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §9;
[issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md);
[ADR 0144](../../02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md) and
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) (the machine's login is
the authority on loopback). The records module was searched on 2026-10-10 for `setuid`, `not judged yet`,
`loopback` and `cannot tell the operator from an agent`; the hits are the records above.
`no_new_privs`, `NoNewPrivileges`, `Landlock`, `bubblewrap` and `ProtectSystem` appear in no record.
What the kernel and the tools do is stated from their documentation and has **not been measured on any node**
yet. Each such claim is marked *to measure* where the decision would rest on it.
## 1. What the setuid search answers, and what it does not
ADR 0266 §3 judges eleven ways to root by reading the machine. Item 10, the setuid search, is the only one
whose cost grows with the data on the machine. On the control-node it did not finish within its 15-minute
bound (measured 2026-10-10, `mesh-controller.root-free`), so the verdict is *not judged* and ADR 0259 §8
blocks every approval proven there.
The search asks whether a program exists that could make the account root. It does not ask whether the
agent's session can run one. Even when it finishes, ADR 0266 lists what it leaves unseen: a packaged setuid
program with a flaw of its own, file capabilities on a program, setuid programs on filesystems not searched.
## 2. The three parts, compared
### 2.1 No new privileges
`no_new_privs` is a per-process flag (Linux 3.5 and later). It is set by `prctl(PR_SET_NO_NEW_PRIVS)`, by
`setpriv --no-new-privs`, or by the service manager's `NoNewPrivileges=yes`. Any process may set it, without
privilege. Once set it is inherited by every child and cannot be cleared. Under it, `execve` grants no
setuid, no setgid and no file capability.
| Way to root (ADR 0266 §3) | Under `no_new_privs` |
|---|---|
| 3, 4: a sudo or doas rule | inert: `sudo` and `doas` are setuid programs |
| 5, 6: polkit through `pkexec` | inert (setuid); polkit **through D-Bus** to a root daemon is not touched |
| 7, 8: a container runtime's socket or TCP port | not touched: the request goes to a root daemon |
| 10: unpackaged setuid program | inert, on every filesystem, searched or not |
| *not judged*: file capabilities, a flawed packaged setuid program | inert |
| *not judged*: a root-run file the account can write; root's keys | not touched |
So it removes the whole class "a program that gains privilege when executed", and leaves the class
"**a root daemon that does something when asked**": the container runtime, the service manager over D-Bus
(polkit), the ssh daemon (a key into another account), cron and at, and any file root later reads.
**It has an escape that must be closed: a process the session asks to start something.** The flag passes
only from parent to child. If the agent account has a running user manager, `systemd-run --user` starts a
command as a child of that manager, which root started without the flag. The same holds for a cron or at
daemon, for D-Bus activation on a session bus, and for a terminal multiplexer server started before the
confined session. Either the account has none of these, or each is itself started with the flag (a drop-in
on the account's `user@` unit can set `NoNewPrivileges=yes`; *to measure*).
### 2.2 A confined view of the files
| | Service manager unit (`ProtectSystem=strict`, `ProtectHome=tmpfs`, `BindPaths=`, `PrivateTmp=`, `TemporaryFileSystem=`, `InaccessiblePaths=`) | Landlock | bubblewrap |
|---|---|---|---|
| Kernel | mount namespaces, present on every kernel the mesh runs | an LSM, 5.13 or later, and enabled in the kernel's LSM list; features grow by ABI (5.19, 6.2, 6.7, 6.10, 6.12) — *to measure on each node* | user namespaces; refused where unprivileged user namespaces are switched off |
| Who sets it up | root (a system unit, or `systemd-run` as root with `User=`); `claude-agent` already goes through the operator's sudo | the process itself, unprivileged, and it requires `no_new_privs` first | the process itself, unprivileged where user namespaces are allowed; sets `no_new_privs` itself |
| Shape | hides or makes read-only whole trees; can show only a list of paths | an allow list of path rights, checked on access; nothing is hidden from a directory listing above an allowed path | a new mount tree built from bind mounts: only what is listed exists |
| What the node-engine can read | the unit's properties from the service manager, and the session's processes' cgroup | `/proc/<pid>/status` shows `NoNewPrivs`; whether a Landlock domain is applied is not readable from outside on older kernels — *to measure* | `NoNewPrivs` and the mount namespace in `/proc`; the bind list is not declared anywhere the engine reads |
| Also gives | `RestrictSUIDSGID=`, `SystemCallFilter=`, `RestrictNamespaces=`, resource limits, one cgroup for every process of the session | per-process, stackable; can be added inside any of the other two | Claude Code's own sandbox on Linux is built on it |
| Interactive terminal | `systemd-run --pty`; *to measure* with `use_pty` from the `sudo` module | unchanged | unchanged |
**Claude Code's own sandbox** (from its documentation, *to measure* on the installed version): on Linux it
runs the shell tool's commands, and their children, under bubblewrap with a write allow list and a network
proxy. It does **not** cover the session's file tools (read, edit, write), which permission rules govern,
nor the session's MCP servers. The shell tool also takes a per-call override that runs a command outside the
sandbox, which a setting can forbid. So it confines the commands an agent types, not the session, and it is
configured by the session's own settings. It is a useful inner layer; it is not the boundary ADR 0259 can
rest on.
**Composability.** The three nest: a unit started by root gives the outer view and `no_new_privs`; Landlock
can narrow further inside it; Claude Code's bubblewrap runs inside both if the unit allows user namespaces
(`RestrictNamespaces=` would forbid it, and with it a class of kernel exploits; a trade-off, see §5). The
guard ([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md))
runs inside the session as a hook and keeps refusing; the confinement makes a refusal it misses fail in the
kernel. The tool runner runs outside, as the operator account, and the session reaches it over loopback, so
the confinement must leave the loopback network open.
**"Nothing else" is a short list, not nothing.** A program needs the dynamic linker's cache, the account
database, the resolver file, the certificate store (the agent talks to its API over TLS), `/proc`, `/dev/null`
and its terminal. Which `/etc` files the agent needs, and whether `/etc` is read-only or hidden but for them,
is to be listed by trying it.
### 2.3 Everything else through the mesh's tools
Measured on 2026-10-10 with `mesh_overview` and `mesh_search`: every node holds `node-service-manager`, whose
`journal` verb reads one unit's journal, and `node-hostname`, whose `entries` verb reads the machine's `/etc/hosts`.
**No seat reads an arbitrary file, lists a directory or reads a log file that is not a unit's journal.**
Today that is done through `node-login-shell.execute`, which runs any command as the operator account, and
which the control-node withholds ([ADR 0268](../../02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)).
A node filesystem seat cannot simply read a caller's path: the tool runner acts for any caller, and ADR 0266
§7 calls a tool that reads a caller's path as the operator or as root a defect. A shape that would hold
follows the store's reader after issue 337: it reads only what a module **declares readable** (its
configuration, its logs), never a secret, through no symbolic link. Verbs it needs, at least: read a declared
file, list a declared directory, read a declared log's tail with a filter.
On the operator's machines the agent works in the operator's desktop and files (ADR 0266 §1). Moving it to an
agent account there is only possible once such tools, and the desk's own tools, cover that work, and issue
344 gaps 3 and 4 (the display, the chat client) need the agent account to have no access to the operator's
display at all. Confining the agent while it **stays** the operator account does not hold: it can write a
file the operator later runs with sudo (a shell start file), and the operator's own user manager starts
anything without the flag.
## 3. What must then be proven for root-free
A confined start proves nothing if another way into the account exists. The proof becomes two halves.
**The doors (read from the machine's configuration, cheap).**
1. The account has no password: a locked entry in the shadow database.
2. No ssh key logs into it: no `authorized_keys` under its home, or the ssh daemon refuses the account.
3. No cron table and no at job for it, or no cron or at daemon runs.
4. No lingering user manager, and no user units under its home; or its `user@` unit carries
`NoNewPrivileges=yes` and the same confinement.
5. Sudo rules only **toward** it (the operator to the agent account, through `claude-agent`), none from it.
ADR 0266 §3 item 3 already judges the second half.
6. `claude-agent` starts the session as the confined unit, and the unit's declaration is the mesh's.
**The session itself (read from `/proc`, cheap, and closer to the truth than the doors).**
7. Every process running as the agent account has `NoNewPrivs: 1` in its status and sits in the cgroup of
a confined session unit. A process that does not is a door nobody listed, found by its effect.
**The daemons that still act on request** (ADR 0266 §3 items 5 to 9 and part of the *not judged* list) stay
judged as today, because `no_new_privs` does not reach them. The confined view can also hide their entry
points: the container runtime's socket and the system bus can be made inaccessible to the session, which a
unit property states and the engine reads.
Every one of these reads is bounded by the number of processes and a handful of files, not by the size of the
disks. Item 7 is checked at a moment; a process started between two looks is seen at the next one, within the
engine's interval.
**What becomes of the setuid search.** Three choices, for the operator (Q2):
- **Retired for agent accounts** once 1 to 7 hold: it judges nothing a confined session can use.
- **Kept as defence in depth**, resumable and unbounded, with a finding raising a warning but no longer
gating `root-free`.
- **Kept only for accounts that are not confined**: the operator account where agents still run as it, and
any account whose processes fail item 7.
## 4. What this does not cover
- **The mesh MCP server has no login of its own** (ADR 0144, ADR 0152: the machine's login is the authority
on loopback; ADR 0266 §7: the tool runner acts for any caller). A confined agent reaches the same tools as
the operator. Every tool that runs a caller's command or reads a caller's path as another account remains a
way round, as ADR 0266 §7 and issue 344 gaps 2, 5 and 11 already record. Research
[039](../039-a-tool-that-needs-a-persons-approval/00-overview.md) is the work that narrows it.
- **A kernel exploit.** `no_new_privs` and a confined view do not stop a flaw in the kernel. A system call
filter and no new user namespaces shrink the surface; the second conflicts with Claude Code's bubblewrap
sandbox (§2.2). The residual risk is the kernel's, as for every other process on the machine.
- **Network reach.** The session keeps the network it needs for its API and the loopback MCP server; a
network allow list is a separate question.
## 5. Open questions for the operator
1. **Is the confined start the boundary ADR 0259 rests on**, replacing the setuid search as the gate for
`root-free` on a node with an agent account? Recommendation: yes, because it answers "can the session
become root" instead of "does a program exist", and its cost does not grow with the data on the machine.
2. **What becomes of the setuid search** (§3)? Recommendation: keep it resumable as defence in depth for a
warning, gate `root-free` only on unconfined accounts.
3. **Which mechanism carries the confinement?** Recommendation: a service manager unit started by root
through `claude-agent`, because it needs no new package, gives `no_new_privs` and the view in one
declaration the node-engine can read; Landlock as an optional inner layer where the kernel has it; Claude
Code's bubblewrap sandbox kept for the shell tool, with its per-call override forbidden.
4. **May the session keep user namespaces**, so that Claude Code's sandbox works inside it, at the price of
a larger kernel surface? Or is the outer unit enough and the inner sandbox dropped?
5. **Is a node filesystem seat that reads only declared paths enough** for what agents do on the
control-node, and is it the precondition for moving the operator's machines to an agent account?
## 6. Next measurements
- On each node: the kernel's LSM list and Landlock ABI, whether unprivileged user namespaces are allowed, and
whether `sudo -iu` there starts a user manager for the target account. No mesh tool reads the first two
today; that is itself a gap for the node-engine's facts.
- A trial `claude-agent` as a transient unit on a non-control node: which `/etc` files the session needs, that
`sudo -n true` fails with the flag set, that `systemd-run --user` is refused or inherits the flag, and that
the mesh MCP server and the guard still answer.
- The `/proc` sweep of item 7 on the control-node: how many processes run as the agent account, and how long
the read takes.
@@ -1,86 +0,0 @@
---
status: graduated
initiated: 2026-10-10
touches:
- 02-DECISIONS/0019-how-this-repository-works.md
- 02-DECISIONS/0080-the-development-cycle-is-checked.md
- 00-META/process/03-issues.md
- 00-META/glossary.md
- the runtime's state verbs and the SDK's state interface
became:
- 03-DESIGN/01-to-be/53-the-issue-tracker.md
- 02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md
---
# 042 — The mesh keeps its own issues
## What
Whether the mesh should keep its issues in a module of its own, on the bus, instead of only as
numbered folders in this repository's `04-ISSUES/`, and what the smallest useful first version of
that module is. The operator's ask of 2026-10-10: work on a ticketing system on the bus, consolidate
every current issue into it, start small and extend the module along the way.
The effort answers six questions, in [01-findings.md](01-findings.md):
1. The smallest useful first slice: what the module holds, which tools it serves, which events it
emits, and what it explicitly leaves out.
2. Where the truth lives during and after the move: the folders here, the module, or a split
between them, and what each choice does to the public-repository rule, playbook 03 and the hq
checks.
3. How the 357 issue folders are imported and consolidated (duplicates, still-open ones, grouping).
4. How the controller's conditions, the router's asks and deliveries relate to issues.
5. How the operator sees issues: through the mesh's tools first, through a view onto the bus later.
6. The open questions for the operator, and the slices after the first.
The operator also named an existing project as inspiration: *hits*, a headless issue tracker built
as NATS micro services on JetStream, with an MCP server for agents. Its model is studied in the
findings, along with whether to reuse its code or build on its model.
## Why
Numbers collide. Playbook 03 tells an author to take the next free number across `main` and every
open pull request, and [issue 155](../../04-ISSUES/155-two-records-may-share-a-number-and-nothing-says-so/00-report.md)
records the second collision reaching `main` with every check passing. `cycle.py` now refuses two
records sharing a number, which catches a collision after it happens and does not prevent one. On
`main` at the time of writing, 31 commits mention a renumbering. On 2026-10-07 three decision numbers
collided in one evening. On 2026-10-10 an open pull request still carries a branch named for issue
349 and a title naming issue 350, because 349 was taken on `main` first. The operator's answer on
2026-10-07 was that numbering belongs to the mesh's own issue tracker.
Status is also hard to see. Of 357 issue folders on `main`, 73 are not closed (27 `open`, 45
`located`, 1 `diagnosing`). Finding them means reading frontmatter across the repository.
Nothing connects a condition the controller raises to the issue that would fix it: on 2026-10-10 a
condition was silenced with the words "hq issue to follow", and nothing records whether one followed.
## What it touches
- [ADR 0019](../../02-DECISIONS/0019-how-this-repository-works.md) (statuses live in frontmatter) and
[ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md) (the cycle is checked), with
playbook [03](../../00-META/process/03-issues.md) and `00-META/checks/cycle.py`.
- [ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
(a module keeps its state in key-value buckets it declares),
[ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
(tools announce themselves on the bus) and
[ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) (events are declared).
- The conditions of [ADR 0231](../../02-DECISIONS/0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)
and the asks of [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).
- The records module, which indexes this repository for agents.
**Graduated 2026-10-10** into [ADR 0275](../../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md)
and [to-be 53](../../03-DESIGN/01-to-be/53-the-issue-tracker.md). The operator delegated the five open questions; each
was decided as recommended. `touches:` names what the decision changes.
## Words
The glossary's word for one of these is **issue** (*The record*: "a report in `04-ISSUES/`"). This
effort keeps that word for the thing the module holds, and proposes **issue tracker** for the module
that holds them, with the module named `issues`. It does not use *ticket*, the operator's word in
the ask, or *item*, the word hits uses. If the glossary entry is widened (an issue is then held by
the issue tracker, and its narrative may live in `04-ISSUES/`), *ticket* would go on its *Not:* line.
That change belongs to the decision this effort graduates into, not to the effort itself.
## Open questions for the operator
Listed in [01-findings.md § 7](01-findings.md#7-open-questions-for-the-operator), each with a
recommendation.
@@ -1,259 +0,0 @@
# 01 — Findings
Measured on 2026-10-10 against `main` of this repository, the open pull requests on the forge, the
controller's open conditions, the module SDK's source, and a clone of the hits repository at its
release v0.7.0. Each section ends with what this effort recommends; section 7 lists what needs the
operator.
## 0. What exists today, measured
| Fact | Measured |
|---|---|
| Issue folders on `main` | 357, numbered up to 362; numbers 283 and 284 were never used, and 338, 341 and 350 sit on open pull requests (363 too) |
| Status in frontmatter | 283 `resolved`, 1 `wontfix`, 45 `located`, 27 `open`, 1 `diagnosing` |
| Frontmatter keys | `status`, `opened`, `located-in` on all 357; `fixed-by` 356; `amended-design` 344; `replay` 22; `replay-none` 44 |
| Size | 1.9 MiB of markdown in all; median folder 4.6 KiB, largest 25 KiB; 244 folders hold only a report, 111 also a diagnosis |
| Links between issues | 168 folders link another issue's folder (265 links); 193 name another issue in prose |
| `located-in` | free text: the controller is written `mesh-controller`, `cmd/mesh-controller`, `mesh-control` and as file paths; 17 issues name nothing |
| Renumberings | 31 commits on `main` mention one; one open pull request still carries the old number in its branch name |
| The forge's own issues | none in this repository or the controller's; [issue 178](../../04-ISSUES/178-a-routed-name-resolves-to-a-provider-merely-told-it/00-report.md) was filed there once and moved here, because "its numbers collide with hq's pull request numbers in conversation" |
| Conditions | 10 open; 5 of kind `healer-wanted` (a hand repair repeated, "an automatic repair is wanted"); one silenced with "hq issue to follow" |
Two things follow. The collisions come from allocating a number by reading a repository that many
branches write at once; nothing short of one allocator removes them. And the frontmatter is a small,
regular data set (six keys, 1.9 MiB) that fits easily in the bus's key-value store under the limits
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
§6 sets (256 KiB a value, 64 MiB a bucket).
## 1. The first slice
**A Go module `issues`, one instance on the control-node, serving one seat held once for the mesh.**
Its state is in buckets it declares (ADR 0201), its tools announce themselves on the bus
([ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)),
and every change is an event it declares under `emits`
([ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md)). The module that delivers pull
requests already has this shape: Go, one mesh seat, two state buckets, declared events.
**What one issue holds in v1**, the frontmatter of today plus what the folders carry in prose:
- `number` (the key), `title`, `status` (`open`, `diagnosing`, `located`, `resolved`, `wontfix`),
`opened`, `closed`;
- `located-in`, from a registered list of repositories and modules rather than free text;
- `fixed-by` (pull requests or commits), `amended-design`, `replay` or `replay-none`;
- `notes`, appended and never edited: who wrote, when, and the text;
- `links`, typed: to another issue (`duplicates`, `relates-to`), to a pull request, a decision
record, a design, a condition key or a delivery;
- `record`: the path of the issue's folder in this repository, when it has one (section 2).
**Storage.** One bucket `issues` with one key per issue, keeping a short history per key, and a
`counter` key for the next number. The events (`opened`, `changed`, `noted`, `linked`) carry what
changed and who changed it, so they are the audit trail, and a view can watch the bucket. Section 6
compares this with a stream as the source of record.
**Tools** (verbs of the seat, found with `mesh_search`):
| Tool | Does |
|---|---|
| `open` | takes the next number atomically and writes the issue; refuses a title or report that is empty |
| `get` | one issue, whole |
| `list` | by status, by `located-in`, by a phrase in title or notes; compact rows, newest first |
| `move` | changes status, enforcing today's cycle rules at the write: `located` needs `located-in`; `resolved` needs `fixed-by`; a core issue needs `replay` or `replay-none`; `wontfix` needs a reason |
| `note` | appends to the trail |
| `link` / `unlink` | adds or removes a typed link |
| `import` | reads this repository's issue folders and writes them as issues with their own numbers (section 3); idempotent, reporting what it would change before it does |
**The caller is the bus credential, not an argument.** The module records who acted from the
identity the runtime gives it with each call, so an agent cannot write as someone else.
**Atomic numbering needs compare-and-set.** The SDK's state interface (v0.1.9) offers `get`, `put`,
`delete`, `keys` and `watch`, and no write that is refused when the key changed since it was read.
Whether the runtime's `mesh/state.put` takes an expected revision was not checked; the tool runner's
code was not read for this effort. Two ways out: add `create` and an `update` that takes the revision
read to the state interface (small, and every module with a counter gains it), or run exactly one
instance and number inside it. **Recommended: the first.** One instance is also right for v1, but the
counter should not depend on it.
> **Correction — 2026-10-10.** The finding above is wrong about the SDK. Version v0.1.9, the one read, had no
> compare-and-set, but the SDK's Go state interface already had it from go/v0.1.10: `Create` (writes a key only if it
> does not exist) and `Update` (writes only at the revision the caller read), with the runtime's matching state
> verbs, both from [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).
> What the tracker's counter needed and slice 1 added is `Increment`, in go/v0.1.12, which reads a counter and writes
> the next number with `Create` or `Update` at the revision it read, retrying a bounded number of times. The
> recommendation stands; the work it named was smaller than stated. ADR 0275 carries the same correction.
**Explicitly left out of v1:** decision and research numbers (slice 2), any change to playbook 03 or
the hq checks (slice 2), duplicate finding beyond links (slice 3), opening an issue from a condition
(slice 4), a web view (slice 5), full-text or semantic search, priorities, claims, notifications, and
the forge's own issues.
## 2. Where the truth lives
| | (a) Split: the tracker holds the issue, hq holds the narrative | (b) Everything moves | (c) The tracker mirrors hq |
|---|---|---|---|
| Number | tracker | tracker | hq, so collisions stay |
| Status, `located-in`, `fixed-by`, links | tracker | tracker | hq frontmatter, copied |
| Report and diagnosis | hq folder for a design or governance question; notes in the tracker for the rest | tracker notes | hq |
| Public-repository rule | holds: hq keeps only scrubbed narrative, the tracker is private and may name nodes | the public record of design questions is lost | holds |
| Playbook 03 | changes: open in the tracker first, write a folder only for a design or governance question | replaced | unchanged |
| `cycle.py` | its issue rules move into the tracker's `move`, refused at the write instead of at merge | same | unchanged, and a second copy to keep in step |
| Records module | still finds the narrative; the tracker's `list` and `get` serve status | finds no issues | unchanged |
Option (c) keeps the collisions and adds a second copy that can drift, so it answers neither of the
operator's problems. Option (b) loses the public record that hq exists to be
([ADR 0019](../../02-DECISIONS/0019-how-this-repository-works.md)), and with it the
symptom-to-component memory that playbook 03 says is never deleted.
**Recommended: (a).** Every issue gets a number from the tracker, including ones that never need a
document here (a node's quirk, an operational follow-up, which playbook 03's table already sends
elsewhere). A design or governance issue also gets a folder `04-ISSUES/NNN-slug/` with the tracker's
number, whose frontmatter shrinks to a pointer (`issue: NNN`) and whose prose stays reviewed in pull
requests. Status lives in one place, the tracker. This reverses ADR 0019's "statuses live only in
frontmatter" for issues and moves part of ADR 0080's check, so it needs a decision record that
supersedes those parts, written when this effort graduates.
**Numbering continues.** The tracker's counter starts above the highest number on `main` and on open
branches at the moment of the switch (today 363, so the first new issue is 364). Numbers 283 and 284
stay unused, and a number never moves again.
**During the move**, hq frontmatter stays authoritative until the import has run and been checked
against it, and one pull request then removes the status keys from every report and changes playbook
03 and `cycle.py` together. There is no period in which both are written.
## 3. Importing and consolidating the 357 issues
**Import.** `import` reads the folders through the records module (`records_list`, `records_read`),
over the bus, rather than from a checkout. Each folder becomes an issue with its own number, its
frontmatter as fields, its title from the heading, the commit it was read at, and its path as
`record`. Folder-to-folder links (265) become `relates-to` links. The four open pull requests'
issues (338, 341, 350, 363) are reserved as numbers and imported when they merge.
**Consolidating** means four concrete passes, each producing a list for the operator or an agent to
confirm, never applying itself:
1. **Close what is fixed.** A `located` or `open` issue whose `located-in` repository has a merged
pull request naming its number is a candidate for `resolved`. hits's auditor (section 6) checks
exactly this against git and is the model.
2. **Merge duplicates.** Candidates are pairs that link each other and share a `located-in`, and
pairs whose titles share most words. A confirmed duplicate is closed `wontfix` with a
`duplicates` link and the reason, keeping its number and folder.
3. **Normalise `located-in`** onto the registered list (`mesh-controller`, `mesh-catalog`, the
node-engine's repository, a module's name), keeping the file paths as a note.
4. **Group the 73 not closed** by domain (the glossary's domains, ADR 0244) and by `located-in`, so
the operator sees, for example, how many open issues the delivery domain carries.
## 4. Conditions, asks and deliveries
A condition is one open fact, raised and cleared only by observation (ADR 0231); an issue is a
question someone must answer. They are different things, and a condition must not be closed by
closing an issue. But a condition can say that a fix is owed: five of today's ten are `healer-wanted`,
and one silence says "hq issue to follow".
Recommended for a later slice (slice 4): an issue links a condition by its key; the controller shows
the link on the condition; and for `healer-wanted` the controller opens one issue per condition key
when it first raises it, linked both ways, instead of an agent remembering to. A silence whose reason
names an owed issue should carry its number. Asks
([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))
stay with the router: the tracker asks nothing in v1. Deliveries relate through pull requests: when
the forge says a pull request merged, the tracker notes it on every issue the pull request names, and
offers `resolved` (slice 4). Nothing in v1 depends on these.
## 5. How the operator sees issues
First through the mesh's tools: `list` returns rows short enough to read in a chat, `get` one issue
whole, and every agent session reaches them through the mesh MCP server. The responder (`@mesh`,
ADR 0234) answers from read verbs, so "what is open in delivery" works on a channel once the seat
exists. Later a view onto the bus: the `issues` bucket watched over the bus (a browser reaches it through a WebSocket listener, which this effort did not check for),
read-only, which the operator named as a future direction for every module's state. Nothing in v1
blocks it, because the state is already a bucket and every change an event.
## 6. hits: what it is, and what to take from it
**What it is.** A headless issue tracker in Go (module `github.com/impire-io/hits`), with no web
interface and no HTTP interface: NATS micro services on JetStream, a command line for people and a
stdio MCP server for agents. One author, 81 commits between 2026-09-02 and 2026-09-14, release
v0.7.0, about 9,700 lines of Go outside tests and 21 test files, tested against an embedded NATS
server. Its model descends from a method like this repository's: a sibling `hits-hq` with numbered
playbooks and decisions.
**Data model.** An *item* has a type (`bug`, `task`, `improvement`), a status (`open`, `diagnosing`,
`located`, `blocked`, `resolved`, `wontfix`), a priority, `located-in` (from a registered list of
*projects*), `fixed-by` (pull request, commit or action), `amended-design`, append-only notes, typed
links (`duplicates`, `relates-to`), a claim, a block, and a tombstone for a filing mistake. Its
statuses and fields are this repository's frontmatter almost key for key. Projects, *initiatives*
(groups of projects that also scope the numbering) and *releases* are registered lists that are
never reused once retired.
**How it stores.** The source of record is a stream `hits-ops` with subjects
`hits.ops.item.<id>` (and `project`, `initiative`, `release`). Every write appends an operation,
published with the per-subject expected last sequence, so two writers to one item cannot both win.
A bucket `hits-state` holds the folded snapshot of each item, the registered lists and one counter
per initiative. The counter is advanced by compare-and-set and an item is numbered only after it is
validated, so a refused create spends no number. On start it rebuilds the bucket from the stream.
**Services and subjects.** One micro service `hits` with 23 endpoints under `hits.api.*` (`create`,
`get`, `edit`, `transition`, `claim`, `release`, `block`, `unblock`, `link`, `unlink`, `note`,
`tombstone`, and register, list and retire for projects, initiatives and releases); three index
services, each a projection of the stream: full text (`hits.search.query`, a local index on disk),
a graph (`hits.graph.neighbors`, `hits.graph.walk`) and an optional semantic search that needs an
outside embedding provider.
**MCP tools.** 27, one per endpoint: `create_item`, `get_item`, `edit_item`, `transition_item`,
`claim_item`, `release_item`, `block_item`, `unblock_item`, `link_items`, `unlink_items`,
`note_item`, `tombstone_item`, the project, initiative and release tools, and `search_items`,
`semantic_search`, `graph_neighbors`, `graph_walk`. One server serves one agent under one actor.
Its command line also has an auditor that checks every item's `fixed-by` against git and finds
merged pull requests whose items are still open.
| | Adopt as it is | Adapt to the mesh |
|---|---|---|
| Statuses, fields, append-only notes, typed links, tombstone | yes: they are already ours | — |
| Stream of operations as the record, bucket as its projection | the pattern | the stream and bucket declared in the manifest and created by the controller (ADR 0201 §3), not created by the service on start |
| Expected-sequence writes; compare-and-set counter; number only after validation | yes | needs compare-and-set in the state interface (section 1) |
| The auditor | the idea, as a tool | reads the forge through the gitea module, not a local clone |
| Subjects `hits.api.*`, its own MCP server | no | the seat's verbs, served by the tool runner and found with `mesh_search` |
| Actor named by the caller | no | the caller is the bus credential the mesh issued |
| Contexts, OAuth device flow, semantic search | no | the mesh issues credentials; no outside provider |
| *item*, *project*, *initiative*, *release* | no | *issue*; a repository or module; no initiatives in v1; *release* is already a homonym here (glossary) |
**Reuse its code, or build on its model?** Its licence is the Sustainable Use License 1.0: use and
modification are allowed for internal business, non-commercial or personal use, and distribution only
free of charge and for non-commercial purposes. It is not an open-source licence. Running it on a
personal mesh is allowed; vendoring it into the catalogue binds the catalogue to that licence for as
long as the code is there. Its services create their own stream and bucket on start, which ADR 0201
§3 forbids a module to do; its subjects carry no prefix and assume one tracker per bus account; its
words clash with the glossary. Changing those means a fork of a two-week-old, single-author project.
v1 here is the item store without the indexes: roughly the size of the module that delivers pull
requests.
**Recommended: build our own module on hits's model,** and say so in the module's README. Do not
depend on or vendor its code. Revisit only if its licence changes, or if its author agrees to a
licence for this use.
## 7. Open questions for the operator
1. **Should the tracker hold every issue's number and status, while hq keeps a folder only for design
and governance issues, pointing at the tracker by number (option (a) in section 2)?**
Recommended: yes. It ends the collisions, keeps the public record, and moves the cycle rules into
the write, where they refuse instead of report.
2. **Should we build our own `issues` module on hits's model rather than run or vendor hits?**
Recommended: build our own. hits's licence limits distribution, its services create their own bus
resources (against ADR 0201), and its words clash with ours. Its model, its compare-and-set
numbering and its auditor are worth copying.
3. **Should the tracker also hand out decision and research numbers?** Recommended: yes, in slice 2.
Decision numbers collided three times on 2026-10-07; the same counter mechanism serves any
sequence, and the tracker then reserves the number when a branch starts.
4. **Should the word stay *issue*, with the module named `issues` and *ticket* retired?**
Recommended: yes. *Issue* is the glossary's word for this, and one word per thing (ADR 0244).
5. **Should the controller open an issue by itself for a `healer-wanted` condition, or only link
one an agent or the operator opened?** Recommended: link only, until slice 4 has run for a week;
then open by itself for `healer-wanted`, one issue per condition key.
## 8. Slices after the first
| Slice | Delivers | Done when the operator sees |
|---|---|---|
| 1 | The `issues` module: open, get, list, move, note, link, import; compare-and-set in the state interface | the next issue number comes from the tracker and the 73 not closed are listed by one call |
| 2 | Decision and research numbers from the tracker; playbook 03 and `cycle.py` changed; status keys removed from every report | two agents opening at once get two numbers, and no hq pull request is renumbered |
| 3 | Consolidation: the four passes of section 3, and an audit tool for `fixed-by` against the forge | the not-closed list shrinks by what was already fixed or duplicated, each with its reason |
| 4 | Conditions and pull requests linked; `healer-wanted` opens an issue | a condition shows its issue, and a merge notes the issues it names |
| 5 | A read-only view onto the `issues` bucket | the operator reads issues in a browser without an agent |
@@ -38,11 +38,6 @@ hand.
### What a domain module turns out to be, and why it is not the one refused above
> **Narrowed, not replaced — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The catalogue no longer
> holds a domain module: `networking` was retired, and every machine is assigned the private network's
> own module. A module with requirements and no files stays a thing a module may be; "why it is worth
> having" below describes what this record decided for `networking`, not what runs.
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
exists and is not one — but only if the difference is stated, so it is stated here.*
@@ -8,13 +8,6 @@ reconstructed: false
# 19. How this repository works
> **Superseded in part — 2026-10-07, by [ADR 0248](0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md).**
> The paragraph "the index is written, not only generated on demand" no longer holds: a stored list was
> a line every decision pull request added to one file, so two open at once always conflicted (issue
> 297). The list of records is generated when it is read, and `02-DECISIONS/README.md` writes only the
> topics in their reading order. Everything else here stands, including the reading order and the check
> that every record has a topic one of the README's.
*Consolidated 2026-08-28 from ten records that were one decision seen from ten angles. The
reasoning is kept; the fragmentation is not.*
@@ -8,8 +8,6 @@ reconstructed: false
# 39. What the SDK holds, and what it refuses
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** The test of this record — frequent *and* cascading does not belong — stands and now applies to one SDK per language. Where *what it holds* names the broker client and the event consumer, read: the local protocol a tools bundle speaks to the node's runtime; the transport lives in the runtime and in no SDK, which is what keeps a bus change from rebuilding any module in any language.
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
## Context
-2
View File
@@ -78,8 +78,6 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
generic apply primitives and platform detection, so it runs where none of those exist — an Android
phone has no ufw, systemd, pacman or Docker.
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
## Consequences
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
@@ -9,10 +9,6 @@ extends: 0027-a-provision-names-what-the-consumer-is-coupled-to.md
# 44. A public name is provisioned, not registered by hand
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The interface stands; its one provider,
> `cloudflare-dns`, left the catalogue, assigned nowhere and required by nothing. A mesh that needs a
> public name made for it adds a provider of `public-dns` again.
## Context
The mesh names and resolves its own machines internally: the overlay generates
@@ -9,8 +9,6 @@ extends: 0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
# 47. A module runs its code as its own process, with its own account
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** A module's *tools* are no longer served by the module's own process under its own account: one tool runtime per node, on the host side, serves every assigned module's bundle. A tool is still served on its own subject and only the module that serves it answers; what moved is the process and the account.
## Context
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
@@ -108,14 +108,6 @@ declared slug is a strictly better escape hatch than an opaque hash. **D stays r
## Consequences (of E)
> **The mechanism changed — 2026-10-06, by [ADR 0225](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md).**
> Option C, named above as the later refinement, is taken: each offer states the longest identity its
> backend keeps, and a consumer is bounded by the provision it requires rather than by 20 everywhere.
> 20 stays the bound of the object store and of a provider that is told its consumers and does not
> say. The overflow is refused before merge by the catalogue check, and at composition the consumer
> is left out of its provider's grants and reported — never the provider's machine refused. What
> stands: the identity is said once, the slug is the remedy, nothing is hashed or truncated.
- A module manifest gains an optional `slug`; a node may carry one too. `ConsumerIdentity` prefers
the slug over the cleaned name for each half. `identityLimit` becomes 20 (the true minimum), and
`CheckIdentity` refuses at `module add` / assignment — now with a message naming the slug to set.

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