Author SHA1 Message Date
jschoubben cb4b3c2398 Prove a push over SSH from g14 (issue 955) 2026-10-17 02:30:30 +02:00
71 changed files with 1626 additions and 6385 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Decide the shape first:
```
then `# NNN. The title`, the title given to the tracker. **No status key**: the issue's status, owner and fix
live in the tracker (ADR 0275), and the cycle test refuses `status:`, `located-in:`, `fixed-by:`,
live in the tracker (ADR 0275), and `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`,
`amended-design:`, `replay:` or `replay-none:` in any report. Notes and links go to the
tracker.
4. Write the symptom **as observed**, in plain terms, with the evidence that it happened.
+1 -1
View File
@@ -44,6 +44,6 @@ generated on demand and never written back to disk.
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. the index test fails if the list is stored there again.
is why that went too. `index.py` fails if the list is stored there again.
- Do not infer status from prose. Trust only a design's frontmatter, the tracker for an issue and the
knowledge base for a record; if one is wrong, flag it.
+1 -1
View File
@@ -15,7 +15,7 @@ works — plus the engineering practice that holds across everything Novox build
## Rules
- Markdown only, but for the checks and one drawn overview of the playbooks (`process/playbooks.html`).
- Markdown only.
- **Stable by nature.** Changes here reflect a genuine shift in intent, not iteration. The one
exception is `how-we-build.md`, which changes whenever a rule is earned — and only through
its amendment process.
+14 -18
View File
@@ -1,19 +1,15 @@
# Checks
The four checks are Go tests (ADR 0527), in this folder (`*_test.go`) with `go.mod` at the root: run
`go test ./...` from the root. The checker runs the same as `mesh/check` on every pull request. Each check is a
function over a file tree that returns its problems, tested on hq's tree and on a deliberately broken tree built
in memory. The Python files and `merge-check.sh` are gone (issue 1296).
```
records_test.go structure: links, no record written here, citations in the form "ADR NNNN"
index_test.go the reading order's topics are written, and no list of records is stored
cycle_test.go the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter
words_test.go the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
python3 00-META/checks/records.py structure: links, no record written here, citations in the form "ADR NNNN"
python3 00-META/checks/index.py the reading order's topics are written, and no list of records is stored
python3 00-META/checks/cycle.py the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter
python3 00-META/checks/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
```
A failing test fails `go test`, so it can be a gate rather than a report. With `MESH_CATALOG_DIR` set, the words
test also compares the catalogue's copies of the retired words and the daemons' lines with the glossary.
Non-zero exit on any problem, so it can be a gate rather than a report.
**Why this exists.** Until now nothing in this repository was verified by anything but reading,
which is how a superseded decision stayed live in the constitution for days and in
@@ -30,7 +26,7 @@ indistinguishable from one that cannot.
| `no-records` | `02-DECISIONS/` and `01-RESEARCH/` hold their README and nothing else: a decision record or research effort lives in the knowledge base (ADR 0290 rule 9) | written with the change that removed the records (issue 426); it failed on a record file put back in `02-DECISIONS/` before it passed |
| `decisions-form` | every entry of a design's `decisions:` is "ADR NNNN", four digits | written with issue 426; it failed on an entry `ADR 290` before it passed |
| `mention-form` | every "ADR N" in the text has four digits, and every "research N" three; a code span is an example and is not read | written with issue 426; it failed on `ADR 12` and `research 45` before it passed |
| *(index test)* | the reading order's six topics are written in `02-DECISIONS/README.md`, none twice, and the README stores no list of records (ADR 0248). The knowledge base reads these lines and refuses a record whose topic is not one of them | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
| *(index.py)* | the reading order's six topics are written in `02-DECISIONS/README.md`, none twice, and the README stores no list of records (ADR 0248). The knowledge base reads these lines and refuses a record whose topic is not one of them | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
## Where the rules on records went
@@ -41,7 +37,7 @@ named in its refusal: `knowledge-base.propose` refuses a record that breaks one
record, naming every reason at once, and the four rules that judge the whole record set run after every
acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, one table-test case per rule).
| Rule here until issue 426 (the Python checks, deleted with issue 1296) | Where it lives now |
| Rule here until issue 426 | Where it lives now |
|---|---|
| `records.py` `rests-on`, for a record's `extends:` | the knowledge base's `rests-on` at propose, and the whole-set rule `rests-on-live` |
| `records.py` `rests-on`, for a design's `decisions:` naming an accepted record | **nowhere yet**: this repository cannot read the knowledge base. The reviewer checks each new entry with `knowledge-base.get`. Designs become the knowledge base's own kind under issue 428 |
@@ -51,7 +47,7 @@ acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, on
| `records.py` `partial-supersession` | the knowledge base's `changes` link to a clause, `changes-in-force` and `supersedes-accepted`, and the whole-set rule `partial` |
| `records.py` `clauses` | the knowledge base's `has-clause`, `clause-numbering`, `clause-lead` and `clause-size` |
| `records.py` `numbering` | the knowledge base's `title` and `reserved` |
| `records.py` `topics`, `index.py` (a record's topic) | the knowledge base's `topic`, against the reading order the index test still checks |
| `records.py` `topics`, `index.py` (a record's topic) | the knowledge base's `topic`, against the reading order `index.py` still checks |
| `records.py` `insights` | the knowledge base's `insight-form`; an accepted record changes by a new version marked as a progressive insight |
| `index.py --print` | `knowledge-base.holds` with a topic |
| `cycle.py` research: a known status, `became:` when graduated | the knowledge base's `research-status`, `overview` and `became` |
@@ -76,7 +72,7 @@ acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, on
State what incident it would have caught, and make it fail before you make it pass. A check
whose failure has never been observed is a guess about its own correctness.
## The cycle test
## cycle.py
The development cycle, checked (ADR 0080):
every design parses, its `layer:` matches its folder and its `status:` is known; a to-be design names a
@@ -88,9 +84,9 @@ and no status key — `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
that carried them before they were removed. The rules on an issue's status this check held until then — an
owner for a located issue, a fix for a resolved one, and a core issue's `replay` or `replay-none` (ADR 0237)
— are refused at the write by `issue-tracker.move`. Its rules on research efforts and decision records left
with them (the table above).
with them (the table above). `python3 00-META/checks/cycle.py`
## The words test
## words.py
One word per thing, checked (ADR 0244).
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
@@ -105,7 +101,7 @@ of the tools' retired words (`retired-words`) with the glossary.
It also fails on a **daemon's line** (ADR 0272,
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
(`daemonLines` in the words test); a command that is a line only with an argument after it is matched as a line is written. Looked
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
for in the same documents, from 2026-10-09 for issues, in the document's own words — code blocks and
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
(`retired-lines`) with the list.
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""The development cycle, checked.
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
frontmatter, because a status this repository holds lives in frontmatter and nowhere else.
What is enforced:
design every 03-DESIGN doc parses, carries `layer:` matching its directory, and a
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
design without a decision -- and once `in-progress` or `implemented` it names
its owning code (`code:`) -- no development without a design that says where.
issues every report has frontmatter, and no two records share a number -- the number is
how a record is cited. An issue's status, owner, fix and replay live in the issue
tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move`
refuses at the write what this check refused at merge until then: a status outside
the five, `located` without an owner, `resolved` without a fix, a core issue
resolved without its replay (ADR 0237), `wontfix` without a reason. A report
carries none of those keys, only the day it was `opened:` -- the tracker holds
them, and two places holding one fact drift. The keys the reports opened before
the import carried were removed once the import held them; the commit it read
is on every imported issue.
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
rules this check held over them are the knowledge base's now: a research effort's status and what it
became, and a decision's duplicate number, are refused by `knowledge-base.propose`; an accepted decision
cited by nothing is the knowledge base's whole-set rule `cited`; a deferral that names no issue is its
rule `deferral`. README.md beside this file says where each went.
Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what
was decided).
python3 00-META/checks/cycle.py
"""
import glob
import os
import re
import sys
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
# The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275,
# slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report
# carrying one of them is a status written in two places, whenever it was opened.
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved")
def rel(path):
return os.path.relpath(path, ROOT)
def frontmatter(path):
"""The YAML block between the first two --- lines, as {key: raw-value-string}.
Minimal on purpose, like records.py: enough for the fields these checks read. A list
value (block or inline) is joined into its items; a scalar stays a string.
"""
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n(.*?)\n---", text, re.S)
if not m:
return None
front, out, key = m.group(1), {}, None
for line in front.split("\n"):
item = re.match(r"^\s+-\s*(.+?)\s*$", line)
if item and key:
out[key].append(item.group(1))
continue
kv = re.match(r"^([A-Za-z-]+):\s*(.*)$", line)
if not kv:
continue
key, value = kv.group(1), kv.group(2).strip()
if value.startswith("[") and value.endswith("]"):
out[key] = [v.strip() for v in value[1:-1].split(",") if v.strip()]
elif value == "":
out[key] = [] # a block list may follow; stays [] if nothing does
else:
out[key] = value
return out
def listy(front, key):
v = front.get(key)
if v is None:
return []
return v if isinstance(v, list) else ([v] if str(v).strip() else [])
def main():
failures = []
def bad(path, why):
failures.append(" %s: %s" % (rel(path), why))
# ---- design ------------------------------------------------------------------------
for layer, name in (("00-as-is", "as-is"), ("01-to-be", "to-be")):
for path in sorted(glob.glob(os.path.join(ROOT, "03-DESIGN", layer, "*.md"))):
if os.path.basename(path) == "README.md":
continue
front = frontmatter(path)
if front is None:
bad(path, "no frontmatter")
continue
if front.get("layer") != name:
bad(path, "layer is %r; this directory is %s" % (front.get("layer"), name))
status = front.get("status")
if status not in DESIGN_STATUSES:
bad(path, "status %r is not one of %s" % (status, sorted(DESIGN_STATUSES)))
if name == "to-be":
if not listy(front, "decisions"):
bad(path, "names no decisions -- no design without a decision")
if status in ("in-progress", "implemented") and not listy(front, "code"):
bad(path, "status %s but code: names no owner -- no development "
"without a design that says where" % status)
# ---- issues ------------------------------------------------------------------------
# Two records may not share a number. Numbers are taken as "next free after main", and work
# sits on unmerged branches for days -- so two people reading the same main allocate the same
# number, and nothing said so. It happened twice in one evening between two machines, and the
# second collision landed on main with all three checks passing (issue 155). An issue number is
# how every other record cites this one; two records answering to it means a pointer that
# resolves to whichever the reader happened to open.
seen = {}
for folder in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", ""))):
name = os.path.basename(os.path.normpath(folder))
number = name.split("-", 1)[0]
if not number.isdigit():
continue
if number in seen:
bad(os.path.join("04-ISSUES", name),
"is numbered %s, and so is %s -- an issue number is how it is cited, and two "
"records answering to one means a citation that resolves to whichever the reader "
"opened. Take the next free number across main AND every open pull request"
% (number, seen[number]))
else:
seen[number] = name
# Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue
# tracker, and its `move` refuses at the write what this check refused at merge. A report carries none
# of those keys.
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
front = frontmatter(path)
if front is None:
bad(path, "no frontmatter")
continue
opened = str(front.get("opened") or "")
if not opened:
bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
continue
carried = [key for key in TRACKER_KEYS if key in front]
if carried:
bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker "
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here"
% ", ".join("`%s:`" % key for key in carried))
checked = (
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
)
if failures:
print("cycle: %d document(s) break the development cycle:" % len(failures))
print("\n".join(failures))
return 1
print("cycle: %d documents checked, the chain holds" % checked)
return 0
if __name__ == "__main__":
sys.exit(main())
-169
View File
@@ -1,169 +0,0 @@
package checks
import (
"fmt"
"io/fs"
"path"
"sort"
"strings"
"testing"
"testing/fstest"
)
// The development cycle, checked (cycle.py until ADR 0527; ADR 0080). Everything here reads frontmatter only,
// because a status this repository holds lives in frontmatter and nowhere else. The decision records and research
// efforts live in the knowledge base (ADR 0290), whose rules judge them.
var designStatuses = map[string]bool{"proposed": true, "designed": true, "in-progress": true, "implemented": true, "abandoned": true}
// The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275), and
// `issue-tracker.move` enforces the cycle's rules at the write. A report carrying one is a status in two places.
var trackerKeys = []string{"status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved"}
// checkDesigns: every design parses, carries `layer:` matching its folder and a known `status:`; a to-be design
// names a decision, and once in-progress or implemented names its owning code. As-is designs need no decisions.
func checkDesigns(fsys fs.FS) []string {
var out []string
for _, layer := range []struct{ folder, name string }{{"00-as-is", "as-is"}, {"01-to-be", "to-be"}} {
entries, _ := fs.ReadDir(fsys, "03-DESIGN/"+layer.folder)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || e.Name() == "README.md" {
continue
}
p := path.Join("03-DESIGN", layer.folder, e.Name())
front := frontmatter(readFile(fsys, p))
if front == nil {
out = append(out, "cycle: "+p+": no frontmatter")
continue
}
if front.scalar("layer") != layer.name {
out = append(out, fmt.Sprintf("cycle: %s: layer is %q; this directory is %s", p, front.scalar("layer"), layer.name))
}
status := front.scalar("status")
if !designStatuses[status] {
var known []string
for s := range designStatuses {
known = append(known, s)
}
sort.Strings(known)
out = append(out, fmt.Sprintf("cycle: %s: status %q is not one of %v", p, status, known))
}
if layer.name == "to-be" {
if len(front.list("decisions")) == 0 {
out = append(out, "cycle: "+p+": names no decisions -- no design without a decision")
}
if (status == "in-progress" || status == "implemented") && len(front.list("code")) == 0 {
out = append(out, fmt.Sprintf("cycle: %s: status %s but code: names no owner -- no development without a design that says where", p, status))
}
}
}
}
return out
}
// checkIssues: no two issue folders share a number (issue 155: the number is how a record is cited), every report
// has frontmatter with `opened:`, and none carries a key the tracker holds.
func checkIssues(fsys fs.FS) []string {
var out []string
entries, _ := fs.ReadDir(fsys, "04-ISSUES")
seen := map[string]string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
number := strings.SplitN(e.Name(), "-", 2)[0]
if number == "" || strings.Trim(number, "0123456789") != "" {
continue
}
if first, ok := seen[number]; ok {
out = append(out, fmt.Sprintf("cycle: 04-ISSUES/%s: is numbered %s, and so is %s -- an issue number is how it is cited, and two records "+
"answering to one means a citation that resolves to whichever the reader opened. Take the next free number across main AND every open pull request", e.Name(), number, first))
} else {
seen[number] = e.Name()
}
}
for _, e := range entries {
p := path.Join("04-ISSUES", e.Name(), "00-report.md")
if !e.IsDir() {
continue
}
if _, err := fs.Stat(fsys, p); err != nil {
continue
}
front := frontmatter(readFile(fsys, p))
if front == nil {
out = append(out, "cycle: "+p+": no frontmatter")
continue
}
if front.scalar("opened") == "" {
out = append(out, "cycle: "+p+": opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
continue
}
var carried []string
for _, k := range trackerKeys {
if _, ok := front[k]; ok {
carried = append(carried, "`"+k+":`")
}
}
if len(carried) > 0 {
out = append(out, fmt.Sprintf("cycle: %s: carries %s, but an issue's status, owner, fix and replay live in the issue tracker (ADR 0275): "+
"move it with `issue-tracker.move`, and keep no copy here", p, strings.Join(carried, ", ")))
}
}
return out
}
func TestCycleDesignsHoldTheChain(t *testing.T) { expectNone(t, checkDesigns(rootFS())) }
func TestCycleIssuesHoldTheChain(t *testing.T) { expectNone(t, checkIssues(rootFS())) }
func design(layer, status, extra string) *fstest.MapFile {
return doc("---\nlayer: " + layer + "\nstatus: " + status + "\n" + extra + "---\nbody\n")
}
func TestCycleDesignsCatchesEachBreak(t *testing.T) {
broken := fstest.MapFS{
"03-DESIGN/00-as-is/a-nofront.md": doc("no frontmatter"),
"03-DESIGN/00-as-is/b-wronglayer.md": design("to-be", "implemented", ""),
"03-DESIGN/00-as-is/c-badstatus.md": design("as-is", "done", ""),
"03-DESIGN/01-to-be/d-nodecision.md": design("to-be", "designed", ""),
"03-DESIGN/01-to-be/e-nocode.md": design("to-be", "in-progress", "decisions: [ADR 0001]\n"),
"03-DESIGN/01-to-be/README.md": doc("not a design"),
}
got := checkDesigns(broken)
expectProblem(t, got, "a-nofront.md: no frontmatter")
expectProblem(t, got, `b-wronglayer.md: layer is "to-be"; this directory is as-is`)
expectProblem(t, got, `c-badstatus.md: status "done" is not one of`)
expectProblem(t, got, "d-nodecision.md: names no decisions")
expectProblem(t, got, "e-nocode.md: status in-progress but code: names no owner")
if len(got) != 5 {
t.Errorf("exactly the five breaks, as-is needs no decisions and a README is no design: %q", got)
}
}
func TestCycleDesignsAcceptsASoundPair(t *testing.T) {
sound := fstest.MapFS{
"03-DESIGN/00-as-is/a.md": design("as-is", "implemented", ""),
"03-DESIGN/01-to-be/b.md": design("to-be", "in-progress", "decisions: [ADR 0001]\ncode: [mesh-sdk]\n"),
}
expectNone(t, checkDesigns(sound))
}
func TestCycleIssuesCatchesEachBreak(t *testing.T) {
report := func(front string) *fstest.MapFile { return doc("---\n" + front + "---\nbody\n") }
broken := fstest.MapFS{
"04-ISSUES/100-a/00-report.md": report("opened: 2026-10-01\n"),
"04-ISSUES/100-b/00-report.md": report("opened: 2026-10-01\n"),
"04-ISSUES/101-noopened/00-report.md": report("title: x\n"),
"04-ISSUES/102-tracker/00-report.md": report("opened: 2026-10-01\nstatus: open\nreplay: none\n"),
"04-ISSUES/103-nofront/00-report.md": doc("plain"),
"04-ISSUES/notes/readme.md": doc("not an issue"),
}
got := checkIssues(broken)
expectProblem(t, got, "04-ISSUES/100-b: is numbered 100, and so is 100-a")
expectProblem(t, got, "101-noopened/00-report.md: opened: says nothing")
expectProblem(t, got, "102-tracker/00-report.md: carries `status:`, `replay:`")
expectProblem(t, got, "103-nofront/00-report.md: no frontmatter")
if len(got) != 4 {
t.Errorf("exactly the four breaks: %q", got)
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""The reading order of the decision records: its topics are written here, its records are not.
A number identifies a record and never changes, so the reading order is not the order of the numbers.
It is the six topics `02-DECISIONS/README.md` writes down, in order, and each record's own topic. The
records live in the knowledge base (ADR 0290, issue 426), which reads these lines through the records
module and refuses a record whose topic is not one of them; the list of records under a topic is its
answer to `knowledge-base.holds` with that topic, never stored here (ADR 0248, hq issue 297).
python3 00-META/checks/index.py check
It fails when the README's reading order is missing or names a topic twice, and when the README stores
a list of records again — the index markers, or a line of a generated list. The last one is what keeps
the conflict of issue 297 from coming back. Whether every record's topic is one of the six is the
knowledge base's rule `topic`, judged when a record is proposed.
"""
import io
import re
import sys
README = "02-DECISIONS/README.md"
# A topic in the README's reading order, e.g.
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
# The knowledge base (mesh-knowledge, reference.go) reads the same line, so the form is a contract.
TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M)
# What a stored list looks like: the old markers, or a line of the list index.py used to print.
STORED = [
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of a generated list"),
]
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 main():
if "--print" in sys.argv:
print("index: the records live in the knowledge base (ADR 0290). Read a topic's with "
"`knowledge-base.holds` and that topic, through the mesh MCP server.")
return 2
text = io.open(README, encoding='utf-8').read()
topics = reading_order(text)
problems = []
if not topics:
problems.append("%s writes no reading order: no line of the form "
"'1. **Label** — `topic: name`. ...'; the knowledge base refuses every "
"record without it" % 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 a list of records (%s). The records live in the knowledge base, "
"and a topic's list is `knowledge-base.holds` with that topic" % (README, what))
for p in problems:
print("index: " + p)
if problems:
return 1
print("index: %d topics in the reading order, no stored list" % len(topics))
return 0
if __name__ == "__main__":
sys.exit(main())
-93
View File
@@ -1,93 +0,0 @@
package checks
import (
"fmt"
"io/fs"
"regexp"
"strings"
"testing"
"testing/fstest"
)
// The reading order of the decision records (index.py until ADR 0527): its topics are written in
// 02-DECISIONS/README.md, its records are not. The knowledge base reads these lines and refuses a record whose
// topic is not one of them, so the form is a contract; a topic's records are `knowledge-base.holds` with that
// topic, never stored here (ADR 0248, issue 297).
const readmeOfDecisions = "02-DECISIONS/README.md"
// A topic line: `1. **Label** — `topic: name`. ...`
var topicLineRx = regexp.MustCompile(`(?m)^\d+\.\s+\*\*([^*]+)\*\*\s+—\s+` + "`topic:\\s*([^`]+)`")
// What a stored list looks like: the old markers, or a line of the list index.py used to print.
var storedLists = []struct {
rx *regexp.Regexp
what string
}{
{regexp.MustCompile(`<!--\s*index:(start|end)\s*-->`), "an index marker"},
{regexp.MustCompile(`(?m)^- \*\*\d{4}\*\* — \[`), "a line of a generated list"},
}
func readingOrder(text string) []string {
var topics []string
for _, m := range topicLineRx.FindAllStringSubmatch(text, -1) {
topics = append(topics, strings.TrimSpace(m[2]))
}
return topics
}
func checkIndex(fsys fs.FS) []string {
text := readFile(fsys, readmeOfDecisions)
topics := readingOrder(text)
var out []string
if len(topics) == 0 {
out = append(out, "index: "+readmeOfDecisions+" writes no reading order: no line of the form '1. **Label** — `topic: name`. ...'; "+
"the knowledge base refuses every record without it")
}
seen := map[string]bool{}
for _, topic := range topics {
if seen[topic] {
out = append(out, fmt.Sprintf("index: %s names a topic twice in its reading order: %s", readmeOfDecisions, topic))
break
}
seen[topic] = true
}
for _, s := range storedLists {
if s.rx.MatchString(text) {
out = append(out, fmt.Sprintf("index: %s stores a list of records (%s). The records live in the knowledge base, and a topic's list is "+
"`knowledge-base.holds` with that topic", readmeOfDecisions, s.what))
}
}
return out
}
func TestIndexReadingOrderIsWrittenAndStoresNoList(t *testing.T) {
expectNone(t, checkIndex(rootFS()))
if n := len(readingOrder(readFile(rootFS(), readmeOfDecisions))); n == 0 {
t.Error("the reading order has no topic")
}
}
const sound = "1. **One** — `topic: one`. x\n2. **Two** — `topic: two`. y\n"
func TestIndexCatchesAMissingReadingOrder(t *testing.T) {
expectProblem(t, checkIndex(fstest.MapFS{readmeOfDecisions: doc("nothing written here\n")}), "writes no reading order")
}
func TestIndexCatchesADuplicateTopic(t *testing.T) {
broken := fstest.MapFS{readmeOfDecisions: doc(sound + "3. **Again** — `topic: one`. z\n")}
expectProblem(t, checkIndex(broken), "names a topic twice")
}
func TestIndexCatchesAStoredList(t *testing.T) {
for what, stored := range map[string]string{
"an index marker": "<!-- index:start -->\n",
"a line of a generated list": "- **0001** — [a title](x.md)\n",
} {
expectProblem(t, checkIndex(fstest.MapFS{readmeOfDecisions: doc(sound + stored)}), what)
}
}
func TestIndexAcceptsASoundReadme(t *testing.T) {
expectNone(t, checkIndex(fstest.MapFS{readmeOfDecisions: doc(sound)}))
}
-1
View File
@@ -1 +0,0 @@
These tests read the repository's documents (rootFS), outside this directory: mesh-build-go never keeps this package's verdict (issue 1935).
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Structural checks over HQ's own documents.
Every check here exists because the thing it checks for actually happened. See README.md
for which incident is behind which check. Run from the repository root:
python3 00-META/checks/records.py
Exits non-zero if anything fails, so it can be a gate rather than a report.
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
rules that judged them here are the knowledge base's now: `propose` refuses a record that breaks one.
README.md beside this file says where each rule went. What stays here judges what this repository
still holds, and keeps the records from coming back.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
# The two folders the records left. Each keeps a README that points at the knowledge base, and
# 02-DECISIONS/README.md also writes the reading order's topics, which the knowledge base reads.
RECORD_FOLDERS = ("02-DECISIONS", "01-RESEARCH")
POINTERS = {"02-DECISIONS/README.md", "01-RESEARCH/README.md"}
# A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
DECISION_ENTRY = re.compile(r"^ADR \d{4}$")
ADR_MENTION = re.compile(r"\bADRs?\s+(\d+)\b")
RESEARCH_MENTION = re.compile(r"\b[Rr]esearch\s+(\d+)\b")
def markdown_files():
for base, dirs, files in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in (".git", ".claude")]
for name in sorted(files):
if name.endswith(".md"):
yield os.path.join(base, name)
def rel(path):
return os.path.relpath(path, ROOT)
def read(path):
with open(path, encoding="utf-8") as handle:
return handle.read()
def frontmatter(text):
"""Minimal frontmatter reader — enough for the fields these checks use.
Not a YAML parser on purpose: a dependency in a repository that has none, to read a few
scalar fields and one list, would cost more than it returns.
"""
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 4)
if end == -1:
return {}
fields, key = {}, None
for line in text[4:end].split("\n"):
item = re.match(r"^\s+-\s+(.*)$", line)
if item and key:
fields.setdefault(key, []).append(item.group(1).strip())
continue
pair = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line)
if pair:
key = pair.group(1)
value = pair.group(2).strip()
if value.startswith("[") and value.endswith("]"):
# inline list: `decisions: [a, b]`, `code: []`
inner = value[1:-1].strip()
fields[key] = [v.strip() for v in inner.split(",") if v.strip()]
elif value:
fields[key] = value
else:
fields[key] = []
return fields
def unquote(value):
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
return value
class Failures:
def __init__(self):
self.items = []
def add(self, check, location, message):
self.items.append((check, location, message))
def report(self):
if not self.items:
print("records: all checks passed")
return 0
by_check = {}
for check, location, message in self.items:
by_check.setdefault(check, []).append((location, message))
for check in sorted(by_check):
print(f"\n{check} — {len(by_check[check])} problem(s)")
for location, message in by_check[check]:
print(f" {location}\n {message}")
print(f"\nrecords: {len(self.items)} problem(s)")
return 1
def check_links(failures):
"""Every relative link resolves to something that exists."""
for path in markdown_files():
folder = os.path.dirname(path)
for number, line in enumerate(read(path).split("\n"), 1):
for target in LINK.findall(line):
target = target.split("#")[0].strip()
if not target:
continue
if not os.path.exists(os.path.normpath(os.path.join(folder, target))):
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
def check_no_records(failures):
"""No decision record or research effort is written into this repository again (ADR 0290 rule 9).
The knowledge base is the only place a record is read; a file here would be a second copy that
drifts from it, and nothing would read it. The folders keep their pointer READMEs and nothing else.
"""
for folder in RECORD_FOLDERS:
base = os.path.join(ROOT, folder)
if not os.path.isdir(base):
failures.add("no-records", folder, "the folder is gone; it keeps a README that points at the "
"knowledge base (and, for 02-DECISIONS, the reading order the knowledge base reads)")
continue
for current, dirs, files in os.walk(base):
for name in sorted(files + [d + "/" for d in dirs]):
path = rel(os.path.join(current, name))
if path.rstrip("/") in POINTERS:
continue
failures.add(
"no-records",
path,
"a decision record or research effort lives in the knowledge base, never here: "
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)",
)
def check_decisions_entries(failures):
"""A design's `decisions:` names each record as "ADR NNNN".
A to-be design rests on the records it names, and the knowledge base answers for a record by its
number; an entry in another form is a citation nothing can follow.
"""
for path in markdown_files():
if not rel(path).startswith("03-DESIGN/"):
continue
for entry in frontmatter(read(path)).get("decisions", []) or []:
if not DECISION_ENTRY.match(unquote(entry)):
failures.add("decisions-form", rel(path),
f"decisions: entry {entry!r} is not 'ADR NNNN' (four digits)")
def check_mentions(failures):
"""In running text, a decision record is "ADR NNNN" (four digits) and a research effort "research NNN"
(three): the form the knowledge base and the issue tracker read a citation in. A code span is an example,
and is not read."""
for path in markdown_files():
text = read(path)
for number, line in enumerate(text.split("\n"), 1):
line = re.sub(r"`[^`]*`", "", line)
for m in ADR_MENTION.finditer(line):
if len(m.group(1)) != 4:
failures.add("mention-form", f"{rel(path)}:{number}",
f"{m.group(0)!r}: a decision record is named 'ADR NNNN', four digits")
for m in RESEARCH_MENTION.finditer(line):
if len(m.group(1)) != 3:
failures.add("mention-form", f"{rel(path)}:{number}",
f"{m.group(0)!r}: a research effort is named 'research NNN', three digits")
def check_status_against_code(failures):
"""A design document naming specific code may not still call itself `designed`.
**Naming a file is a claim that the file implements this**, so the two fields have to agree.
They drifted: ten to-be documents named working, lab-proven code — several with a *What was
built* or *Raised, and observed* section — while still saying nothing had been built.
Deliberately weak, and that is the point of it being mechanical. It cannot tell whether the
prose is true, only that a document has stopped claiming to be unbuilt once it points at
something. `code: [mesh-controller]` — a repository with no path — is a plan and stays
`designed`.
"""
for path in markdown_files():
if not rel(path).startswith("03-DESIGN/01-to-be/") or path.endswith("README.md"):
continue
front = frontmatter(read(path))
if front.get("status") != "designed":
continue
for entry in front.get("code") or []:
named = re.sub(r"\s*\(.*\)$", "", entry).strip().split(None, 1)
if len(named) > 1:
failures.add(
"status-vs-code",
rel(path),
f"`designed`, but names {named[1]!r} in {named[0]}. Naming a file claims "
f"it implements this — use `in-progress`, or `implemented` once it is "
f"defensible from that repository's main branch.",
)
break
def main():
failures = Failures()
check_links(failures)
check_no_records(failures)
check_decisions_entries(failures)
check_mentions(failures)
check_status_against_code(failures)
return failures.report()
if __name__ == "__main__":
sys.exit(main())
-206
View File
@@ -1,206 +0,0 @@
package checks
import (
"fmt"
"io/fs"
"path"
"regexp"
"strings"
"testing"
"testing/fstest"
)
// Structural checks over hq's own documents (records.py until ADR 0527). The decision records and research
// efforts live in the knowledge base (ADR 0290, issue 426); what stays here judges what this repository still
// holds, and keeps the records from coming back.
var (
linkRx = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
// A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
decisionEntryRx = regexp.MustCompile(`^ADR \d{4}$`)
adrMentionRx = regexp.MustCompile(`\bADRs?\s+(\d+)\b`)
researchMentionRx = regexp.MustCompile(`\b[Rr]esearch\s+(\d+)\b`)
codeSpanRx = regexp.MustCompile("`[^`]*`")
recordFolders = []string{"02-DECISIONS", "01-RESEARCH"}
recordFolderPoints = map[string]bool{"02-DECISIONS/README.md": true, "01-RESEARCH/README.md": true}
)
// checkLinks: every relative link resolves to something that exists.
func checkLinks(fsys fs.FS) []string {
var out []string
for _, p := range markdownFiles(fsys) {
for n, line := range strings.Split(readFile(fsys, p), "\n") {
for _, m := range linkRx.FindAllStringSubmatch(line, -1) {
target := m[1]
if strings.HasPrefix(target, "http:") || strings.HasPrefix(target, "https:") || strings.HasPrefix(target, "mailto:") {
continue
}
target = strings.TrimSpace(strings.SplitN(target, "#", 2)[0])
if target == "" {
continue
}
if _, err := fs.Stat(fsys, path.Join(path.Dir(p), target)); err != nil {
out = append(out, fmt.Sprintf("links: %s:%d: link does not resolve: %s", p, n+1, target))
}
}
}
}
return out
}
// checkNoRecords: the two folders the records left hold their pointer README and nothing else (ADR 0290 rule 9).
func checkNoRecords(fsys fs.FS) []string {
var out []string
for _, folder := range recordFolders {
if _, err := fs.Stat(fsys, folder); err != nil {
out = append(out, "no-records: "+folder+": the folder is gone; it keeps a README that points at the knowledge base")
continue
}
_ = fs.WalkDir(fsys, folder, func(p string, d fs.DirEntry, err error) error {
if err != nil || p == folder || recordFolderPoints[p] {
return nil
}
out = append(out, "no-records: "+p+": a decision record or research effort lives in the knowledge base, never here: "+
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)")
return nil
})
}
return out
}
// checkDecisionsEntries: a design's `decisions:` names each record as "ADR NNNN".
func checkDecisionsEntries(fsys fs.FS) []string {
var out []string
for _, p := range markdownFiles(fsys) {
if !strings.HasPrefix(p, "03-DESIGN/") {
continue
}
for _, e := range frontmatter(readFile(fsys, p)).list("decisions") {
if !decisionEntryRx.MatchString(unquote(e)) {
out = append(out, fmt.Sprintf("decisions-form: %s: decisions: entry %q is not 'ADR NNNN' (four digits)", p, e))
}
}
}
return out
}
// checkMentions: in running text a decision record is "ADR NNNN" and a research effort "research NNN"; a code
// span is an example and is not read.
func checkMentions(fsys fs.FS) []string {
var out []string
for _, p := range markdownFiles(fsys) {
for n, line := range strings.Split(readFile(fsys, p), "\n") {
line = codeSpanRx.ReplaceAllString(line, "")
for _, m := range adrMentionRx.FindAllStringSubmatch(line, -1) {
if len(m[1]) != 4 {
out = append(out, fmt.Sprintf("mention-form: %s:%d: %q: a decision record is named 'ADR NNNN', four digits", p, n+1, m[0]))
}
}
for _, m := range researchMentionRx.FindAllStringSubmatch(line, -1) {
if len(m[1]) != 3 {
out = append(out, fmt.Sprintf("mention-form: %s:%d: %q: a research effort is named 'research NNN', three digits", p, n+1, m[0]))
}
}
}
}
return out
}
var codeEntryRx = regexp.MustCompile(`\s*\(.*\)$`)
// checkStatusAgainstCode: a to-be design naming specific code (a path after the repository) may not still be
// `designed`. Deliberately weak: it cannot tell whether the prose is true, only that a document stopped claiming
// to be unbuilt once it points at something. `code: [mesh-controller]` is a plan and stays `designed`.
func checkStatusAgainstCode(fsys fs.FS) []string {
var out []string
for _, p := range markdownFiles(fsys) {
if !strings.HasPrefix(p, "03-DESIGN/01-to-be/") || strings.HasSuffix(p, "README.md") {
continue
}
front := frontmatter(readFile(fsys, p))
if front.scalar("status") != "designed" {
continue
}
for _, e := range front.list("code") {
named := strings.SplitN(strings.TrimSpace(codeEntryRx.ReplaceAllString(e, "")), " ", 2)
if len(named) > 1 {
out = append(out, fmt.Sprintf("status-vs-code: %s: `designed`, but names %q in %s. Naming a file claims it implements this "+
"- use `in-progress`, or `implemented` once it is defensible from that repository's main branch.", p, strings.TrimSpace(named[1]), named[0]))
break
}
}
}
return out
}
func doc(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
// The real tree.
func TestRecordsLinksResolve(t *testing.T) { expectNone(t, checkLinks(rootFS())) }
func TestRecordsFoldersHoldOnlyPointers(t *testing.T) { expectNone(t, checkNoRecords(rootFS())) }
func TestRecordsDecisionsEntriesForm(t *testing.T) { expectNone(t, checkDecisionsEntries(rootFS())) }
func TestRecordsMentionForm(t *testing.T) { expectNone(t, checkMentions(rootFS())) }
func TestRecordsStatusAgainstCode(t *testing.T) { expectNone(t, checkStatusAgainstCode(rootFS())) }
// The checks themselves, each against a broken fixture, and against a sound one.
func TestRecordsLinksCatchesABrokenLink(t *testing.T) {
broken := fstest.MapFS{
"a/one.md": doc("see [two](two.md) and [gone](gone.md#x) and [web](https://example.com/x)\n"),
"a/two.md": doc("[up](../a/one.md#top) [anchor](#here)\n"),
}
got := checkLinks(broken)
expectProblem(t, got, "a/one.md:1: link does not resolve: gone.md")
if len(got) != 1 {
t.Errorf("only the broken link is a problem, got %q", got)
}
}
func TestRecordsNoRecordsCatchesARecordPutBack(t *testing.T) {
broken := fstest.MapFS{
"02-DECISIONS/README.md": doc("pointer"),
"02-DECISIONS/0001-x.md": doc("a record"),
"01-RESEARCH/README.md": doc("pointer"),
"01-RESEARCH/045/00-x.md": doc("an effort"),
}
got := checkNoRecords(broken)
expectProblem(t, got, "02-DECISIONS/0001-x.md")
expectProblem(t, got, "01-RESEARCH/045")
expectProblem(t, checkNoRecords(fstest.MapFS{"02-DECISIONS/README.md": doc("p")}), "01-RESEARCH: the folder is gone")
}
func TestRecordsDecisionsFormCatchesAShortEntry(t *testing.T) {
broken := fstest.MapFS{
"03-DESIGN/01-to-be/1.md": doc("---\nlayer: to-be\ndecisions:\n - ADR 0290\n - ADR 290\n---\n"),
"00-META/x.md": doc("---\ndecisions: [ADR 5]\n---\n"),
}
got := checkDecisionsEntries(broken)
expectProblem(t, got, `entry "ADR 290"`)
if len(got) != 1 {
t.Errorf("only a design's entries are read, and ADR 0290 is sound: %q", got)
}
}
func TestRecordsMentionFormCatchesShortNumbers(t *testing.T) {
broken := fstest.MapFS{"x.md": doc("fine ADR 0290 and research 045\nbad ADR 12 and research 45\nexample `ADR 7` and `research 9`\n")}
got := checkMentions(broken)
expectProblem(t, got, "x.md:2: \"ADR 12\"")
expectProblem(t, got, "x.md:2: \"research 45\"")
if len(got) != 2 {
t.Errorf("code spans and sound forms are not problems: %q", got)
}
}
func TestRecordsStatusAgainstCodeCatchesADesignedDesignNamingAFile(t *testing.T) {
broken := fstest.MapFS{
"03-DESIGN/01-to-be/9.md": doc("---\nstatus: designed\ncode:\n - mesh-controller internal/x.go (the thing)\n---\n"),
"03-DESIGN/01-to-be/8.md": doc("---\nstatus: designed\ncode: [mesh-controller]\n---\n"),
"03-DESIGN/01-to-be/7.md": doc("---\nstatus: in-progress\ncode: [mesh-controller internal/x.go]\n---\n"),
}
got := checkStatusAgainstCode(broken)
expectProblem(t, got, "03-DESIGN/01-to-be/9.md: `designed`, but names \"internal/x.go\" in mesh-controller")
if len(got) != 1 {
t.Errorf("a plan without a path and an in-progress design are sound: %q", got)
}
}
-39
View File
@@ -1,39 +0,0 @@
package checks
import (
"reflect"
"testing"
"testing/fstest"
)
func TestMarkdownFilesSkipsToolFolders(t *testing.T) {
fsys := fstest.MapFS{
"a.md": {Data: []byte("x")},
"b/c.md": {Data: []byte("x")},
"b/c.txt": {Data: []byte("x")},
".git/d.md": {Data: []byte("x")},
".claude/e.md": {Data: []byte("x")},
".work/f/g.md": {Data: []byte("x")},
}
got := markdownFiles(fsys)
want := []string{"a.md", "b/c.md"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("markdownFiles = %q, want %q", got, want)
}
}
func TestFrontmatterReadsScalarsAndLists(t *testing.T) {
f := frontmatter("---\nlayer: to-be\ndecisions:\n - ADR 0001\n - ADR 0002\ncode: []\ncodes: [a, b]\n---\nbody\n")
if f.scalar("layer") != "to-be" {
t.Errorf("layer = %q", f.scalar("layer"))
}
if !reflect.DeepEqual(f.list("decisions"), []string{"ADR 0001", "ADR 0002"}) {
t.Errorf("decisions = %q", f.list("decisions"))
}
if len(f.list("code")) != 0 || !reflect.DeepEqual(f.list("codes"), []string{"a", "b"}) {
t.Errorf("code = %q, codes = %q", f.list("code"), f.list("codes"))
}
if frontmatter("no frontmatter") != nil || frontmatter("---\nunclosed") != nil {
t.Error("a document without a closed block has no frontmatter")
}
}
-150
View File
@@ -1,150 +0,0 @@
// Package checks holds hq's four checks as Go tests (ADR 0527): records, index, cycle and words.
//
// The package has tests only. Each check is a function over an fs.FS that returns its problems, so
// the test of the real tree reads the repository's root, and the test of the check itself reads a
// deliberately broken tree built in memory and must see the problem named (every check here failed on
// a broken fixture before it passed). The shared reading of Markdown and frontmatter is in this file.
package checks
import (
"io/fs"
"os"
"regexp"
"sort"
"strings"
"testing"
)
// rootFS is hq's tree: the checks run from 00-META/checks, two folders under the root.
func rootFS() fs.FS { return os.DirFS("../..") }
// skipDirs are folders no check reads: git's, the agent tooling's and a session's scratch folder.
var skipDirs = map[string]bool{".git": true, ".claude": true, ".work": true}
// markdownFiles lists every Markdown file of the tree, sorted, in slash paths from the root.
func markdownFiles(fsys fs.FS) []string {
var out []string
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if p != "." && skipDirs[d.Name()] {
return fs.SkipDir
}
return nil
}
if strings.HasSuffix(p, ".md") {
out = append(out, p)
}
return nil
})
sort.Strings(out)
return out
}
func readFile(fsys fs.FS, p string) string {
b, err := fs.ReadFile(fsys, p)
if err != nil {
return ""
}
return string(b)
}
// Front is a document's frontmatter: a scalar field holds a string, a list field a []string.
type Front map[string]any
func (f Front) scalar(key string) string {
s, _ := f[key].(string)
return s
}
func (f Front) list(key string) []string {
switch v := f[key].(type) {
case []string:
return v
case string:
if strings.TrimSpace(v) != "" {
return []string{v}
}
}
return nil
}
var (
fmItem = regexp.MustCompile(`^\s+-\s*(.+?)\s*$`)
fmPair = regexp.MustCompile(`^([A-Za-z_-]+):\s*(.*)$`)
)
// frontmatter reads the block between the first two --- lines. It is not a YAML parser on purpose: a
// dependency to read a few scalar fields and one list would cost more than it returns. It returns nil
// when the document has no frontmatter.
func frontmatter(text string) Front {
if !strings.HasPrefix(text, "---\n") {
return nil
}
end := strings.Index(text[4:], "\n---")
if end == -1 {
return nil
}
out := Front{}
key := ""
for _, line := range strings.Split(text[4:4+end], "\n") {
if m := fmItem.FindStringSubmatch(line); m != nil && key != "" {
list, _ := out[key].([]string)
out[key] = append(list, m[1])
continue
}
m := fmPair.FindStringSubmatch(line)
if m == nil {
continue
}
key = m[1]
value := strings.TrimSpace(m[2])
switch {
case strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]"):
list := []string{}
for _, v := range strings.Split(value[1:len(value)-1], ",") {
if v = strings.TrimSpace(v); v != "" {
list = append(list, v)
}
}
out[key] = list
case value == "":
out[key] = []string{}
default:
out[key] = value
}
}
return out
}
func unquote(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 && v[0] == v[len(v)-1] && (v[0] == '"' || v[0] == '\'') {
return v[1 : len(v)-1]
}
return v
}
// lineOf is the 1-based line of byte offset i in text.
func lineOf(text string, i int) int { return strings.Count(text[:i], "\n") + 1 }
// expectNone fails the test with every problem a check found in the real tree.
func expectNone(t *testing.T, problems []string) {
t.Helper()
for _, p := range problems {
t.Error(p)
}
}
// expectProblem fails unless some problem contains want: the broken fixture must be caught, by name.
func expectProblem(t *testing.T, problems []string, want string) {
t.Helper()
for _, p := range problems {
if strings.Contains(p, want) {
return
}
}
t.Errorf("no problem mentions %q; got %q", want, problems)
}
+2 -14
View File
@@ -1,6 +1,6 @@
# Words allowed — documents the words test does not hold to the glossary's retired words
# Words allowed — documents `words.py` does not hold to the glossary's retired words
Read by the words test (`words_test.go`) (ADR 0244).
Read by [`words.py`](words.py) (ADR 0244).
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
themselves. A row whose document no longer uses a retired word fails too, so this list only shrinks. The
research efforts this list kept as written (research 034 and 042) live in the knowledge base now (ADR 0290),
@@ -8,15 +8,3 @@ which keeps a record's words as it was imported.
| Document | Until | Why |
|---|---|---|
| `04-ISSUES/285-the-merge-gate-compared-broken-to-broken-and-passed/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/300-a-new-module-is-not-built-at-its-merge/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/302-the-labs-go-replays-were-never-checked-before-merge/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/305-a-recheck-left-the-old-verdict-standing/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/336-a-send-held-for-the-bus-step-raises-nothing/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/336-a-send-held-for-the-bus-step-raises-nothing/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/338-a-module-built-from-a-shared-repository-moves-on-every-merge-to-it/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/341-a-merge-check-judges-the-head-not-what-merging-it-makes/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/353-the-controller-asked-before-the-bus-held-its-grant/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person/01-diagnosis.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
| `04-ISSUES/364-the-gate-refuses-a-module-that-uses-a-seat-another-module-declares/00-report.md` | 2027-04-30 | history: the issue's narrative keeps the word of its day, "merge gate" (issue 1303); it is not rewritten |
+377
View File
@@ -0,0 +1,377 @@
#!/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; an issue opened
on or after FROM (on or after LINES_FROM for the lines). The decision records and research efforts live in
the knowledge base (ADR 0290, issue 426), which holds a record proposed there to the same words through its
rules `retired-word` and `daemon-line`, read from this glossary.
A document the check fails on that cannot be reworded in the change that found it is named, with a date
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.
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): issues opened 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 issues(since=FROM):
"""The issue reports opened on or after `since`, file by file."""
out = []
base = os.path.join(ROOT, "04-ISSUES")
if not os.path.isdir(base):
return out
for issue in sorted(os.listdir(base)):
head = os.path.join(base, issue, "00-report.md")
if not os.path.isfile(head):
continue
date = frontmatter_field(read(rel(head)), "opened")
if not date or date < since:
continue
for name in sorted(os.listdir(os.path.join(base, issue))):
if name.endswith(".md"):
out.append("04-ISSUES/%s/%s" % (issue, 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 + 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.
"""
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 not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
problems.append("%s: the allowance for %s has no date" % (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))
-756
View File
@@ -1,756 +0,0 @@
package checks
import (
"fmt"
"io/fs"
"os"
"path"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"unicode"
)
// One word per thing, checked (words.py until ADR 0527; 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 it 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 word's plural is the word.
// unique no head word heads two entries, and none is also struck through on a *Not:* line.
// lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09): the operator types `nox`.
// copy with MESH_CATALOG_DIR set to a checkout of the catalogue, its copies of the retired words and the
// daemons' lines equal this glossary's and this list (skipped, with its reason, when unset).
//
// Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; an issue opened on or after
// wordsFrom (on or after linesFrom for the lines). A document 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 so does an entry for a document that no longer needs it.
const (
glossaryFile = "00-META/glossary.md"
allowedFile = "00-META/checks/words-allowed.md"
// The day the rule began (ADR 0244): issues opened from it on are held to it.
wordsFrom = "2026-10-07"
// The day the daemons' lines rule began.
linesFrom = "2026-10-09"
)
var wordsAlways = []string{"00-META/", "03-DESIGN/", "AGENTS.md", "README.md"}
// daemonLines are the daemons' lines (ADR 0272): each daemon's commands. "alone" is a command that is a line by
// itself; "with an argument" one that is a line only when written as one ("the mesh-controller module" is prose;
// `mesh-controller module check ...` is a line). The node-engine's install-time subcommands are not lines here.
var daemonLines = []struct {
daemon, alone, withArgument string
}{
{"mesh-controller",
"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",
"artifacts ask builds bus collection data delivery images mirrors module node overlay plans queue seat seats take version"},
{"mesh-host", "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version", ""},
{"nox-mesh-host", "hand-over", ""},
}
var (
notLineRx = regexp.MustCompile(`(?m)^\s*(?:[-*]\s+)?\*Not:\*(.*)$`)
struckRx = regexp.MustCompile(`~~([^~]+)~~(?:\s*\((hq|tools)\))?`)
identLineRx = regexp.MustCompile(`(?m)^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$`)
spanRx = regexp.MustCompile("`([^`]+)`")
headRx = regexp.MustCompile(`(?m)^\s*-\s+\*\*([^*]+)\*\*`)
openedRx = regexp.MustCompile(`(?m)^opened:\s*(\S+)`)
)
type retiredWord struct{ word, scope string }
// parseGlossary reads the retired words with their scope, the identifiers and the head words from the glossary.
func parseGlossary(text string) (retired []retiredWord, identifiers, heads []string) {
for _, m := range notLineRx.FindAllStringSubmatch(text, -1) {
for _, s := range struckRx.FindAllStringSubmatch(m[1], -1) {
scope := s[2]
if scope == "" {
scope = "all"
}
for _, one := range strings.Split(s[1], " / ") {
retired = append(retired, retiredWord{strings.TrimSpace(one), scope})
}
}
}
for _, m := range identLineRx.FindAllStringSubmatch(text, -1) {
for _, s := range spanRx.FindAllStringSubmatch(m[1], -1) {
identifiers = append(identifiers, strings.TrimSpace(s[1]))
}
}
for _, m := range headRx.FindAllStringSubmatch(text, -1) {
for _, h := range strings.Split(m[1], " / ") {
heads = append(heads, strings.TrimSpace(h))
}
}
return
}
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-'
}
// findBounded returns the start offsets of the matches of rx that are whole words: no word character or hyphen
// before or after (the lookarounds of the Python check, which RE2 has not).
func findBounded(rx *regexp.Regexp, text string) []int {
var out []int
pos := 0
for pos <= len(text) {
loc := rx.FindStringIndex(text[pos:])
if loc == nil {
break
}
start, end := pos+loc[0], pos+loc[1]
before, after := true, true
if start > 0 {
r := []rune(text[max(0, start-4):start])
before = !isWordRune(r[len(r)-1])
}
if end < len(text) {
r := []rune(text[end:min(len(text), end+4)])
after = !isWordRune(r[0])
}
if before && after {
out = append(out, start)
pos = end
if end == start {
pos++
}
} else {
pos = start + 1
}
}
return out
}
// wordRegexp matches a word whole, case-insensitively; 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").
func wordRegexp(word string) *regexp.Regexp {
var parts []string
for _, p := range strings.Fields(word) {
parts = append(parts, regexp.QuoteMeta(p))
}
return regexp.MustCompile(`(?i)` + strings.Join(parts, `[\s-]+`) + `(?:e?s)?`)
}
// blank replaces every byte of s but the line breaks, so offsets and line numbers hold.
func blank(s string) string {
b := []byte(s)
for i, c := range b {
if c != '\n' {
b[i] = ' '
}
}
return string(b)
}
func blankFrontmatter(text string) string {
if strings.HasPrefix(text, "---\n") {
if end := strings.Index(text[4:], "\n---"); end != -1 {
end += 4
return blank(text[:end+4]) + text[end+4:]
}
}
return text
}
func blankAll(text string, steps []*regexp.Regexp) string {
for _, rx := range steps {
text = rx.ReplaceAllStringFunc(text, blank)
}
return text
}
func compileAll(patterns ...string) []*regexp.Regexp {
var out []*regexp.Regexp
for _, p := range patterns {
out = append(out, regexp.MustCompile(p))
}
return out
}
// What is not the document's own words, in the order it is taken out.
const (
stepComment = `(?s)<!--.*?-->`
stepBlockQuote = `(?m)^\s*>.*$`
stepLinkTarget = `\]\([^)\s]*\)`
stepStruck = `~~[^~\n]+~~`
stepQuoted = `"[^"\n]*"`
stepCurlyDouble = `\x{201c}[^\x{201d}]*\x{201d}`
stepCurlySingle = `\x{2018}[^\x{2019}\n]*\x{2019}`
)
var (
proseSteps = compileAll("(?ms)^\\s*```.*?^\\s*```", stepComment, stepBlockQuote, "`[^`\\n]+`", stepLinkTarget, stepStruck,
stepQuoted, stepCurlyDouble, stepCurlySingle)
// In the glossary the *Not:* and *Identifier until renamed:* lines name the retired words, so they are taken out first.
glossaryProseSteps = append(compileAll(notLineRx.String(), identLineRx.String()), proseSteps...)
// The daemons' lines are looked for in the document's own words: a code block and a code span are kept, since a
// line to type is written in one.
ownWordsSteps = compileAll(stepComment, stepBlockQuote, stepLinkTarget, stepStruck, stepQuoted, stepCurlyDouble, stepCurlySingle)
)
// prose is the text with everything that is not running prose blanked, keeping offsets and line numbers.
func prose(text string, glossaryDoc bool) string {
text = blankFrontmatter(text)
if glossaryDoc {
return blankAll(text, glossaryProseSteps)
}
return blankAll(text, proseSteps)
}
func ownWords(text string) string { return blankAll(blankFrontmatter(text), ownWordsSteps) }
// issueDocuments lists the issue reports' files opened on or after since.
func issueDocuments(fsys fs.FS, since string) []string {
var out []string
entries, _ := fs.ReadDir(fsys, "04-ISSUES")
for _, e := range entries {
if !e.IsDir() {
continue
}
head := readFile(fsys, path.Join("04-ISSUES", e.Name(), "00-report.md"))
front := ""
if strings.HasPrefix(head, "---\n") {
if end := strings.Index(head[4:], "\n---"); end != -1 {
if m := openedRx.FindStringSubmatch(head[4 : 4+end]); m != nil {
front = m[1]
}
}
}
if front == "" || front < since {
continue
}
files, _ := fs.ReadDir(fsys, path.Join("04-ISSUES", e.Name()))
for _, f := range files {
if strings.HasSuffix(f.Name(), ".md") {
out = append(out, path.Join("04-ISSUES", e.Name(), f.Name()))
}
}
}
return out
}
// perDocument runs fn over the documents on all processors (the unit level is short) and joins the problems in
// the documents' order.
func perDocument(docs []string, fn func(doc string) []string) []string {
results := make([][]string, len(docs))
var wg sync.WaitGroup
sem := make(chan struct{}, runtime.NumCPU())
for i, d := range docs {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
results[i] = fn(d)
<-sem
}()
}
wg.Wait()
var out []string
for _, r := range results {
out = append(out, r...)
}
return out
}
func checkedDocuments(fsys fs.FS, since string) []string {
var docs []string
for _, entry := range wordsAlways {
if strings.HasSuffix(entry, "/") {
_ = fs.WalkDir(fsys, strings.TrimSuffix(entry, "/"), func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(p, ".md") {
docs = append(docs, p)
}
return nil
})
} else if _, err := fs.Stat(fsys, entry); err == nil {
docs = append(docs, entry)
}
}
return append(docs, issueDocuments(fsys, since)...)
}
var dateRx = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// parseAllowed reads words-allowed.md: one row per document, | `document` | until | why |. Until is a date, after
// which the allowance fails like the words themselves. The clock is the caller's: today is YYYY-MM-DD.
func parseAllowed(text, today string) (allowed map[string]string, problems []string) {
allowed = map[string]string{}
for _, line := range strings.Split(text, "\n") {
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
if len(cells) != 3 {
continue
}
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
if !strings.HasPrefix(cells[0], "`") {
continue
}
doc, until, why := strings.Trim(cells[0], "`"), cells[1], cells[2]
if why == "" {
problems = append(problems, fmt.Sprintf("%s: the allowance for %s gives no reason", allowedFile, doc))
}
if !dateRx.MatchString(until) {
problems = append(problems, fmt.Sprintf("%s: the allowance for %s has no date", allowedFile, doc))
continue
}
if until < today {
problems = append(problems, fmt.Sprintf("%s: the allowance for %s ran out on %s — reword it", allowedFile, doc, until))
continue
}
allowed[doc] = until
}
return
}
func checkRetired(fsys fs.FS, retired []retiredWord, identifiers []string, today string) []string {
allowance, out := parseAllowed(readFile(fsys, allowedFile), today)
type matcher struct {
word string
rx *regexp.Regexp
why string
}
var words []matcher
for _, r := range retired {
if r.scope == "all" || r.scope == "hq" {
words = append(words, matcher{r.word, wordRegexp(r.word), "retired"})
}
}
for _, i := range identifiers {
words = append(words, matcher{i, wordRegexp(i), "an identifier, allowed only in a code span"})
}
var mu sync.Mutex
used := map[string]bool{}
out = append(out, perDocument(checkedDocuments(fsys, wordsFrom), func(d string) []string {
var found []string
text := prose(readFile(fsys, d), d == glossaryFile)
lower := strings.ToLower(text)
for _, w := range words {
// Every match holds the word's first part literally, so a text without it is not searched (the unit level is short).
if !strings.Contains(lower, strings.ToLower(strings.Fields(w.word)[0])) {
continue
}
for _, start := range findBounded(w.rx, text) {
if _, ok := allowance[d]; ok {
mu.Lock()
used[d] = true
mu.Unlock()
continue
}
m := w.rx.FindString(text[start:])
found = append(found, fmt.Sprintf("%s:%d: %q is %s (glossary)", d, lineOf(text, start), strings.ReplaceAll(m, "\n", " "), w.why))
}
}
return found
})...)
var unused []string
for d := range allowance {
if !used[d] {
unused = append(unused, d)
}
}
sort.Strings(unused)
for _, d := range unused {
out = append(out, fmt.Sprintf("%s: %s no longer uses a retired word — remove its allowance", allowedFile, d))
}
return out
}
func checkUnique(retired []retiredWord, heads []string) []string {
var out []string
seen := map[string]bool{}
for _, h := range heads {
k := strings.ToLower(h)
if seen[k] {
out = append(out, fmt.Sprintf("%s: %q heads two entries", glossaryFile, h))
}
seen[k] = true
}
for _, r := range retired {
if seen[strings.ToLower(r.word)] {
out = append(out, fmt.Sprintf("%s: %q is a head word and also retired", glossaryFile, r.word))
}
}
return out
}
// linesList is the daemons' lines as the catalogue's copy lists them: "<daemon> <command>", with " …" when an
// argument must follow.
func linesList() []string {
var out []string
for _, d := range daemonLines {
for _, c := range strings.Fields(d.alone) {
out = append(out, d.daemon+" "+c)
}
for _, c := range strings.Fields(d.withArgument) {
out = append(out, d.daemon+" "+c+" …")
}
}
sort.Strings(out)
return out
}
func checkLines(fsys fs.FS) []string {
type pat struct {
daemon, command string
rx *regexp.Regexp
bounded bool
}
var pats []pat
for _, d := range daemonLines {
dq := regexp.QuoteMeta(d.daemon)
for _, c := range strings.Fields(d.alone) {
pats = append(pats, pat{d.daemon, c, regexp.MustCompile(dq + `\s+` + regexp.QuoteMeta(c)), true})
}
for _, c := range strings.Fields(d.withArgument) {
// A line only when written as one: at the start of a line or a code span, a prompt allowed, an argument after.
pats = append(pats, pat{d.daemon, c, regexp.MustCompile("(?m)(?:^|`)[ \\t]*(?:\\$[ \\t]*)?(" + dq + `\s+` + regexp.QuoteMeta(c) + "\\s+[^\\s`])"), false})
}
}
return perDocument(checkedDocuments(fsys, linesFrom), func(d string) []string {
var out []string
text := ownWords(readFile(fsys, d))
for _, p := range pats {
if !strings.Contains(text, p.daemon) {
continue
}
var starts []int
if p.bounded {
starts = findBounded(p.rx, text)
} else {
for _, loc := range p.rx.FindAllStringSubmatchIndex(text, -1) {
starts = append(starts, loc[2])
}
}
for _, s := range starts {
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)", d, lineOf(text, s), p.daemon, p.command))
}
}
return out
})
}
func toolsList(retired []retiredWord) []string {
seen := map[string]bool{}
var out []string
for _, r := range retired {
if (r.scope == "all" || r.scope == "tools") && !seen[r.word] {
seen[r.word] = true
out = append(out, r.word)
}
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
return out
}
func readList(file string) ([]string, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var out []string
for _, l := range strings.Split(string(b), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
out = append(out, l)
}
}
return out, nil
}
// checkCopy compares the catalogue's copies of the retired words and the daemons' lines with this glossary's. A
// difference names what the copy should list, so regenerating it is copying that from the failure.
func checkCopy(catalogue string, retired []retiredWord) []string {
var out []string
copyWords, err := readList(path.Join(catalogue, "retired-words"))
if err != nil {
return []string{"the catalogue's copy of the retired words cannot be read: " + err.Error()}
}
sort.Slice(copyWords, func(i, j int) bool { return strings.ToLower(copyWords[i]) < strings.ToLower(copyWords[j]) })
want := toolsList(retired)
if strings.Join(copyWords, "\n") != strings.Join(want, "\n") {
out = append(out, "the catalogue's retired-words differs from the glossary: it should list "+strings.Join(want, ", "))
}
copyLines, err := readList(path.Join(catalogue, "retired-lines"))
if err != nil {
return append(out, "the catalogue's copy of the daemons' lines cannot be read: "+err.Error())
}
sort.Strings(copyLines)
if strings.Join(copyLines, "\n") != strings.Join(linesList(), "\n") {
out = append(out, "the catalogue's retired-lines differs from this check's list: it should list, one per line:\n"+strings.Join(linesList(), "\n"))
}
return out
}
// The real tree.
func today() string { return time.Now().Format("2006-01-02") }
func TestWordsGlossaryNamesRetiredWords(t *testing.T) {
retired, identifiers, heads := parseGlossary(readFile(rootFS(), glossaryFile))
if len(retired) == 0 {
t.Fatal("the glossary names no retired word on a *Not:* line — the check would pass on anything")
}
if len(identifiers) == 0 || len(heads) == 0 {
t.Errorf("the glossary names %d identifiers and %d head words; both are expected", len(identifiers), len(heads))
}
}
func TestWordsEachHeadWordHeadsOneEntry(t *testing.T) {
retired, _, heads := parseGlossary(readFile(rootFS(), glossaryFile))
expectNone(t, checkUnique(retired, heads))
}
func TestWordsNoRetiredWordInRunningProse(t *testing.T) {
retired, identifiers, _ := parseGlossary(readFile(rootFS(), glossaryFile))
expectNone(t, checkRetired(rootFS(), retired, identifiers, today()))
}
func TestWordsNoDaemonLineWritten(t *testing.T) { expectNone(t, checkLines(rootFS())) }
func TestWordsCatalogueCopyAgrees(t *testing.T) {
catalogue := os.Getenv("MESH_CATALOG_DIR")
if catalogue == "" {
t.Skip("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
}
retired, _, _ := parseGlossary(readFile(rootFS(), glossaryFile))
expectNone(t, checkCopy(catalogue, retired))
}
// The checks themselves, against broken trees (H5a: the glossary).
const glossaryFixture = `# Glossary
- **control plane** — the retired one's replacement is below.
*Not:* ~~control plane~~, ~~overlay~~ (hq), ~~alert~~ (tools), ~~a / b~~
- **controller** — decides.
*Not:* ~~control plane~~
- **Controller** — again.
- *Identifier until renamed:* ` + "`mesh-host`, `nox-old`" + ` — the repository
`
func TestGlossaryReadsNotLinesScopesIdentifiersAndHeads(t *testing.T) {
retired, identifiers, heads := parseGlossary(glossaryFixture)
want := []retiredWord{{"control plane", "all"}, {"overlay", "hq"}, {"alert", "tools"}, {"a", "all"}, {"b", "all"}, {"control plane", "all"}}
if fmt.Sprint(retired) != fmt.Sprint(want) {
t.Errorf("retired = %v, want %v", retired, want)
}
if fmt.Sprint(identifiers) != "[mesh-host nox-old]" {
t.Errorf("identifiers = %v", identifiers)
}
if fmt.Sprint(heads) != "[control plane controller Controller]" {
t.Errorf("heads = %v", heads)
}
got := checkUnique(retired, heads)
expectProblem(t, got, `"Controller" heads two entries`)
expectProblem(t, got, `"control plane" is a head word and also retired`)
}
func TestToolsListHoldsTheWordsOfScopeAllAndTools(t *testing.T) {
retired, _, _ := parseGlossary(glossaryFixture)
if got := fmt.Sprint(toolsList(retired)); got != "[a alert b control plane]" {
t.Errorf("toolsList = %s", got)
}
}
// H5b: prose blanking and matching.
func TestProseBlanksWhatIsNotRunningProse(t *testing.T) {
text := "---\nfm: overlay\n---\n" +
"bare overlay one\n" +
"```\noverlay in a block\n```\n" +
"span `overlay` x\n" +
"> quoted overlay\n" +
"say \"overlay\" and “overlay” and ‘overlay’\n" +
"~~overlay~~ struck\n" +
"[link](overlay.md)\n" +
"<!-- overlay\nover lines -->\n" +
"bare overlay two\n"
got := prose(text, false)
if strings.Count(got, "overlay") != 2 {
t.Errorf("only the two bare uses stay: %q", got)
}
if strings.Count(got, "\n") != strings.Count(text, "\n") || len(got) != len(text) {
t.Error("blanking keeps offsets and line numbers")
}
}
func TestProseOfTheGlossaryBlanksNotAndIdentifierLines(t *testing.T) {
got := prose("- **x** — y\n *Not:* control plane, plain\n- *Identifier until renamed:* mesh-host\nthe control plane\n", true)
if strings.Count(got, "control plane") != 1 || strings.Contains(got, "mesh-host") {
t.Errorf("only the running use stays: %q", got)
}
}
func TestWordMatchesWholeWordsPluralsAndLineBreaks(t *testing.T) {
rx := wordRegexp("control plane")
count := func(s string) int { return len(findBounded(rx, s)) }
for s, want := range map[string]int{
"the control plane runs": 1,
"Control Planes": 1,
"control-plane and control\nplane": 2,
"uncontrol plane": 0,
"control planet": 0,
"control plane-x": 0,
"x-control plane": 0,
"control planexs": 0,
} {
if got := count(s); got != want {
t.Errorf("%q: %d matches, want %d", s, got, want)
}
}
}
func TestRetiredCatchesAWordInProseAndNamesDocumentAndLine(t *testing.T) {
retired := []retiredWord{{"control plane", "all"}, {"overlay", "hq"}, {"alert", "tools"}}
broken := mapOf(map[string]string{
"AGENTS.md": "fine\nthe control plane here\n",
"03-DESIGN/01-to-be/a.md": "an overlay and \"a control plane quoted\" and `overlay` alert\n",
"README.md": "mesh-host runs; `mesh-host` too\n",
})
got := checkRetired(broken, retired, []string{"mesh-host"}, "2026-10-23")
expectProblem(t, got, `AGENTS.md:2: "control plane" is retired`)
expectProblem(t, got, `03-DESIGN/01-to-be/a.md:1: "overlay" is retired`)
expectProblem(t, got, `README.md:1: "mesh-host" is an identifier, allowed only in a code span`)
if len(got) != 3 {
t.Errorf("a quotation, a code span and a word of scope tools are not problems: %q", got)
}
}
func TestIssuesAreHeldToTheRuleFromItsDay(t *testing.T) {
retired := []retiredWord{{"overlay", "all"}}
fsys := mapOf(map[string]string{
"04-ISSUES/1-old/00-report.md": "---\nopened: 2026-10-06\n---\noverlay\n",
"04-ISSUES/2-new/00-report.md": "---\nopened: 2026-10-07\n---\nfine\n",
"04-ISSUES/2-new/01-notes.md": "an overlay\n",
"04-ISSUES/3-nodate/00-report.md": "overlay\n",
})
got := checkRetired(fsys, retired, nil, "2026-10-23")
expectProblem(t, got, "04-ISSUES/2-new/01-notes.md:1")
if len(got) != 1 {
t.Errorf("only the issue opened from 2026-10-07 on is read: %q", got)
}
}
// H5c: allowances, with an injected clock.
const allowedFixture = `# Words allowed
| Document | Until | Why |
|---|---|---|
| ` + "`a.md`" + ` | 2026-12-01 | quotes what was |
| ` + "`b.md`" + ` | 2026-10-01 | ran out |
| ` + "`c.md`" + ` | soon | no date |
| ` + "`d.md`" + ` | 2026-12-01 | |
`
func TestAllowedReadsRowsAndTheInjectedClock(t *testing.T) {
allowed, problems := parseAllowed(allowedFixture, "2026-10-23")
if _, ok := allowed["a.md"]; !ok || len(allowed) != 2 {
t.Errorf("a.md and d.md are in date: %v", allowed)
}
expectProblem(t, problems, "the allowance for b.md ran out on 2026-10-01")
expectProblem(t, problems, "the allowance for c.md has no date")
expectProblem(t, problems, "the allowance for d.md gives no reason")
// The same text on an earlier day: b.md is still in date.
if early, _ := parseAllowed(allowedFixture, "2026-09-30"); len(early) != 3 {
t.Errorf("on 2026-09-30 a, b and d are in date: %v", early)
}
}
func TestAllowanceHoldsAWordBackUntilItsDateAndShrinks(t *testing.T) {
retired := []retiredWord{{"overlay", "all"}}
allowedRow := "| `AGENTS.md` | 2026-12-01 | quotes what was |\n"
tree := func(agents, allowed string) map[string]string {
return map[string]string{"AGENTS.md": agents, allowedFile: allowed}
}
expectNone(t, checkRetired(mapOf(tree("an overlay\n", allowedRow)), retired, nil, "2026-10-23"))
expectProblem(t, checkRetired(mapOf(tree("an overlay\n", allowedRow)), retired, nil, "2026-12-02"), "ran out on 2026-12-01")
expectProblem(t, checkRetired(mapOf(tree("clean\n", allowedRow)), retired, nil, "2026-10-23"), "AGENTS.md no longer uses a retired word")
expectProblem(t, checkRetired(mapOf(tree("an overlay\n", "")), retired, nil, "2026-10-23"), `AGENTS.md:1: "overlay" is retired`)
}
// H5d: the daemons' lines and the catalogue comparison.
func TestLinesCatchesADaemonsLineButNotProse(t *testing.T) {
fsys := mapOf(map[string]string{
"AGENTS.md": "the mesh-controller module is prose\n" +
"run `mesh-controller module check x` now\n" +
"or:\n```\n$ mesh-controller push\n```\n" +
"a quotation \"mesh-controller status\" stays\n" +
"> mesh-controller doctor in a block quote\n" +
"a mesh-controller pushed is another word\n",
"04-ISSUES/1/00-report.md": "---\nopened: 2026-10-08\n---\nmesh-host apply\n",
"04-ISSUES/2/00-report.md": "---\nopened: 2026-10-09\n---\nmesh-host apply\n",
})
got := checkLines(fsys)
expectProblem(t, got, "AGENTS.md:2: `mesh-controller module` is a daemon's line")
expectProblem(t, got, "AGENTS.md:5: `mesh-controller push` is a daemon's line")
expectProblem(t, got, "04-ISSUES/2/00-report.md:4: `mesh-host apply`")
if len(got) != 3 {
t.Errorf("prose, quotations, block quotes and issues before 2026-10-09 are not problems: %q", got)
}
}
func TestLinesListNamesEachLineOnce(t *testing.T) {
list := linesList()
seen := map[string]bool{}
for _, l := range list {
if seen[l] {
t.Errorf("%q listed twice", l)
}
seen[l] = true
}
if !seen["mesh-controller push"] || !seen["mesh-controller module …"] || !seen["nox-mesh-host hand-over"] {
t.Errorf("the list lacks a known line: %v", list[:5])
}
}
func TestCatalogueCopyDifferenceIsCaughtAndNamesTheList(t *testing.T) {
retired := []retiredWord{{"overlay", "all"}, {"alert", "tools"}, {"hq only", "hq"}}
dir := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(path.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("retired-words", "# copy\nalert\noverlay\n")
write("retired-lines", strings.Join(linesList(), "\n")+"\n")
expectNone(t, checkCopy(dir, retired))
write("retired-words", "alert\n")
write("retired-lines", "mesh-controller push\n")
got := checkCopy(dir, retired)
expectProblem(t, got, "retired-words differs from the glossary: it should list alert, overlay")
expectProblem(t, got, "retired-lines differs from this check's list")
expectProblem(t, checkCopy(t.TempDir(), retired), "cannot be read")
}
func mapOf(files map[string]string) fs.FS {
m := fstest.MapFS{}
for k, v := range files {
m[k] = doc(v)
}
return m
}
+94 -311
View File
@@ -14,7 +14,7 @@ and the domain decides what that word means. Other domains use the word as it is
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.
Two fixed lines follow an entry where they apply, and the words test in `00-META/checks/` reads them:
Two fixed lines follow an entry where they apply, and `00-META/checks/words.py` reads them:
- *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
@@ -35,7 +35,7 @@ is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
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).
*Context* in that sense is not used in new writing; the word stays ordinary English, so this is
checked by review, not by the words test.
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.
@@ -98,9 +98,6 @@ and repair).
*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.
- **run for** — an assignment, or a start of a module, that serves one agent identity with that identity's machine
account, home and bus password. Only a module whose manifest declares it may be run for an identity; running for
the operator is the default run, as the operator account, with no setting (ADR 0412).
- **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).
@@ -173,10 +170,8 @@ every other domain has something to run on. **Recorded by** the controller and t
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 its node should run: from release 6 of
ADR 0429 its node agent follows the node's wants and the compositions the controller signs for it, one module
at a time (ADR 0539); until then it received the node's whole declaration. It writes the files, runs the
services and containers, and says what it runs in its haves.
- **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:
@@ -229,30 +224,18 @@ every other domain has something to run on. **Recorded by** the controller and t
the two and raises `mirror-drifted` on any difference
(ADR 0289, ADR 0356). Builds, rollouts and assignments are no mirrors: they are
the chain, kept on the bus as their only source (ADR 0408). Shortened to *mirror* where the
text is about the controller's records; the builder's *mirrored* `upstream` image (Delivery) is
text is about the controller's records; the builder's *mirrored* `upstream` image (Change and delivery) is
a different thing, a copy of an outside image in the artifact store.
## Placement — what runs on which node
**Purpose.** To decide, send and apply what each node runs, and to say why.
**Recorded by** the controller's `inventory` database; from ADR 0549, by the catalogue's placement files, which
the indexer reads onto the bus. **Upstream of** Provisioning, Connectivity, Data
and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 0252, 0261, 0411, 0539, 0549, 0559.
**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, manifest (Module), config value (Config).
- **assignment** — a module put on a node. Its config values on that node (module-assignment config,
Config) are `mesh-config`'s, never part of what the controller records (ADR 0348 §9).
- **placement file** — the catalogue's file for one node: the modules placed on it and, for each, its pins, its
host ports and its bindings to the providers it requires. It changes only by a pull request on the catalogue,
and the indexer reads it at each trunk merge into its state `placement`, one key per node, which every reader
of placement watches (ADR 0549 §1 to §3). Until ADR 0547's third milestone the controller's assignments are
kept equal to it.
- **placement candidate** — a placement file of one node as an open catalogue pull request has it at its head,
kept by the indexer in its state `placement-candidates` under `<group>.<node>` while the pull request is open.
Every node composes it in **shadow** (composed and compared, never applied) and answers in its own have whether
what it would run is the same; the composition check passes only when every placed node has answered the same
or differs only where the change intends (ADR 0559). Not the indexer's candidates of a group, which are
fingerprints (Delivery).
- **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).
@@ -273,42 +256,23 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
`package` on `node-package-manager`, a `container` on `node-container-runtime`
(ADR 0207).
Not a claim: a module claims a seat it holds and declares resources.
- **declaration** — every resource of every module assigned to one node, composed, which the controller sent
that node until release 6 of ADR 0429. From release 6 nothing is sent: what a node applies is its
compositions, and the node-engine keeps the declaration it applied last only as what it starts from, its
**last declaration** (ADR 0539 §9). The switch's own last declaration carries the operator's public key
(ADR 0539 §3, §11). 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` previewed a node's declaration until
release 6.
- **composition** — one module's resources composed for one node at one fingerprint, signed by the controller
(or, made by hand with `mesh-hand-compose`, by the operator), written by the controller as
`<node>.<module>.<fingerprint>` in that node's bucket `composed-<node>`, which only that node reads; the node's
own resources (adoption, firewall openings, accounts, the resolver, identities and groups) are its node
composition `<node>.node`. Its digest, with the controller's judgement of the move from what the node runs
now, is kept under the same key in `mesh-controller_composed`; a want names that digest. Derived state, kept
while its fingerprint is among the module's five newest or a want names it, and no link of the chain
(ADR 0539 §1 to §4). From release 6. Not mesh-sdk's composition, the function that composes it (see
[Homonyms](#homonyms)), nor the **composition check** (Delivery).
- **send** — the controller giving a node its declaration, until release 6 of ADR 0429. The controller's verb
`push` asked for a send (of one node, or of every node); prose says send. From release 6 nothing is sent: the
controller writes compositions and the assigner writes wants, and every act that sent writes compositions
instead (ADR 0539 §5). A git push and a phone's push notification are other things (see
[Homonyms](#homonyms)).
- **apply** — the node-engine making its node match what it should run. From release 6 of ADR 0429 its node
agent applies one module at a time, the composition whose digest the module's want names, only when the
want's fingerprint or digest differs from what runs, and removes only that module's own resources
(ADR 0539 §9); until then it applied the node's whole declaration. **reconcile** is doing so again until
nothing differs; from release 6 a failed apply is retried by the next change of the want or the composition,
never by a clock.
- **operator account** — the machine account of the operator's person identity on a node, read from the
identity and no longer 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, ADR 0411).
- **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).
Not a name a manifest carries.
- **agent account** — the machine account of the agent identity the identity seat marks as a node's agent account,
on the operator's Approve, never by an assignment or a config value. Where none is marked, agents run as the
operator account. It cannot
become root without a person, and the node-engine judges that on every look (ADR 0266, ADR 0411).
- **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 config value. 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).
- **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
@@ -376,8 +340,8 @@ and vault (Identity and access).
**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, Delivery, and Operator and conversation.
**Decided by** ADR 0085, 0113, 0183, 0225, 0234, 0386, 0387, 0388, 0411, 0413, 0414, 0550.
**Upstream of** Provisioning, Change and delivery, and Operator and conversation.
**Decided by** ADR 0085, 0113, 0183, 0225, 0234, 0386, 0387, 0388.
**Uses:** module, node, seat, tool (Module), operator, consumer and provision (Provisioning), agent and ask (Operator and conversation).
- **credential** — anything that proves an identity: a password, a key, a token. A **pair credential**
@@ -389,16 +353,10 @@ and the operator act. **Recorded by** the controller's `identity` and `licences`
and never made by the mesh
(ADR 0283).
- **vault** — the module that owns every secret (`mesh-vault`,
[24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)): the mesh's signing key, with which
it signs on request, licence grants and tokens, bus account passwords and module secrets (ADR 0550).
[24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)).
- **identity** — a person, an agent or a module as the mesh knows it: a name, a kind, a role, and whether it
is switched on; a person or an agent also has a login name, the machines it is present on and, for a person,
the login kept on a machine where it differs. Every identity and role is held by the identity seat, in the
bus's key-value store, and changes only through its verbs (ADR 0386, ADR 0411). Each person and agent
identity has a bus account of its own, carrying its role (ADR 0413).
- **machine account** — an identity's login on one machine, made by the mesh from the identity: the
controller composes it and the node-engine applies it, adopting a login that exists and never renaming or
deleting one (ADR 0411).
is switched on. Every identity and role is held by the identity seat, in the bus's key-value store, and
changes only through its verbs (ADR 0386).
- **role** — what an identity may call, as a set of grants (ADR 0386). Not a seat: a bare *role* is a
homonym (see [Homonyms](#homonyms)).
- **grant** — one line of a role: an effect everywhere, an effect on one seat, or one named tool, and may be
@@ -431,62 +389,34 @@ and the operator act. **Recorded by** the controller's `identity` and `licences`
agent can run as that account: the control-node, and a node whose agents run under an account of their own
(ADR 0272).
## Delivery — a commit on its way to the nodes
## Change and delivery — a commit on its way to the nodes
**Purpose.** To take one commit from its pull request to every node that should run it, and to put it
back when it fails. The domain is continuous integration and delivery, named **delivery**, and its parts live in
the repository `mesh-delivery` (ADR 0429 §2). **Recorded by** the `mesh-delivery` module (deliveries) and the
controller (the planner, the rollout) until release 6 of ADR 0429; from each release on, by the parts it builds. **Downstream of** Module, Core, Health and repair and Data.
**Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270, 0276, 0323, 0401–0408, 0428, 0429, 0444–0447, 0524–0530, 0539, 0548, 0551.
back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the
planner, the rollout). **Downstream of** Module, Core, Health and repair and Data.
**Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270, 0276, 0323, 0401–0408.
**Uses:** module, node, declaration and send (Placement), health (Health and repair), person.
- **merge check** — what a pull request carries before it may merge, judging the merge of its head into a
named **trunk commit**, made in a clone of its own on a build node and never pushed (ADR 0323). One status,
`mesh/check`, written by the checker: the change's tests, run by the language's build module, and the
**composition check**; for a delivery group, one verdict on every member's head (ADR 0445, ADR 0447). Before
release 4 of ADR 0429 a pull request carried two statuses, one for the modules the change touches and one
for the repository's own script (ADR 0238).
- **composition check** — whether the machines a change touches still compose with it, run by the assigner
from the modules the indexer and the builder hold, with mesh-sdk's composition, the one the controller
composes with
(ADR 0446). It replaces the controller's check of that kind, which composed every machine from release 4 of
ADR 0429 back (issue 1303).
*Not:* ~~merge gate~~ (hq)
- **kept verdict** — a package's passing verdict at one test level, kept by the checker under the digest of
everything its tests read, written once and read-only; a check takes it instead of running an unchanged
package (ADR 0444).
- **carried verdict** — a delivery's verdict moved to a newer trunk commit, or a newer sibling commit, by its
package digests: the checker merges the head onto the new commit and runs only the packages whose digest the
move changed, keeping the composition while no composed fingerprint changed; the verdict names the commits it
was carried from (ADR 0548 §5, §6). A head git refuses onto the new commit is not carried: its next check is
whole (ADR 0548 §7).
- **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). Both judge the merge of the pull request's head into a named **trunk commit**, made in the
build seat's own clone and never pushed (ADR 0323).
- **trunk commit** — the commit of the trunk a merge check's verdict was judged against: the trunk's head when
the build module cloned it. The verdict holds for that commit only, goes out of date when the trunk moves, and
is then carried to the new commit (ADR 0323, ADR 0548).
- **merge turn** — an approved merge's key on a repository in the gate's `turns` bucket: the deliveries waiting
to merge there in the order of their Approve, and the check each waits for. It holds new checks off that
repository until the merge is done (ADR 0548 §3). Written only while the check gates merges (the gate's
setting `check-gates-merge`); while it does not, no turn is written and an approved merge goes in at once
(ADR 0551 §1).
- **build seat** — a language's node seat, held on a node by that language's build module: `node-build-go`,
`node-build-node` and `node-build-image` (ADR 0525 §2). Its accepted kinds are the **build streams**, and its
verbs `current`, `pause` and `resume` serve that node's pulls. It replaces the seat `node-build-agent`, retired
in R4g (ADR 0525 §10). Any node may hold it, and a node whose **build capacity** is above zero builds, so there is
no build node by nature (ADR 0237).
the build seat cloned it. The verdict holds for that commit only, and goes out of date when the trunk moves
(ADR 0323).
- **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the
**builder**. Any node may hold the seat, so there is no build node by nature
(ADR 0237).
*Not:* ~~build machine~~
- **build queue** — the build requests waiting to run, kept by the bus alone: per language, the work-queue streams
`delivery`, `check` and `recheck`, which a build module pulls from, deliveries first, while its node has a free
slot of the build capacity (ADR 0525 §1, §4; ADR 0536 §4). No part keeps a list of them: the controller's queue
and its verbs are gone, and a stream's backlog and the age of its oldest request are read from its consumers
(ADR 0525 §9). One entry in it is a **build request**, an ask on a build stream for one whole job on one node;
the conversation's ask is another thing (see [Homonyms](#homonyms)).
- **build capacity** — how many build requests a node's build modules hold at once: a setting of the node that the
build modules read, and 0 pulls nothing (ADR 0525 §5). Not a seat's capacity (**capacity**, Module).
- **build module** — the module of one language that does the build steps for it on a node (fetch, build, digest,
test), claiming that language's build seat: `mesh-build-go`, `mesh-build-node` and `mesh-build-image`. It pulls the
build requests of its language and writes each step's end on its own key (ADR 0525 §3, §6). Not the **builder**.
- **build queue** — the builds waiting to run, kept by the bus as three work queues a builder takes in order:
delivery builds, then checks of pull requests marked ready, then every other check; each check queue keeps one
request per pull request, the newest (ADR 0406); 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 build module talks to it.
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
@@ -513,17 +443,14 @@ controller (the planner, the rollout) until release 6 of ADR 0429; from each rel
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,
ADR 0270). From release 6 of ADR 0429 the gate keeps the merge's states alone (`proposed`, `ready`,
`rejected`, `held`, `merged` and `closed`), and a merged delivery is delivered when its source revision is,
derived from the wants and the haves (ADR 0539 §17). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy
ADR 0270). 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 rollout 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 rollout a newer delivery took over before it ended; nor *delivered*, which its own rollout or an empty plan
ends in (ADR 0270). As a state of the gate it goes at release 6; a newer delivered source revision then
covers the older merges of its repository (ADR 0539 §17).
ends in (ADR 0270).
- **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.
@@ -533,78 +460,32 @@ controller (the planner, the rollout) until release 6 of ADR 0429; from each rel
lone delivery (ADR 0401). Work is never held back on a branch other than a trunk: the trunk is what runs.
*Not:* ~~release branch~~ (hq)
- **toolchain** — a language's compiler and tools, installed by that language's build module on every node holding
its build seat, pinned to one version that is the same on every build node; its version is part of a build's reuse
the build seat, pinned to one version that is the same on every builder; its version is part of a build's reuse
key (ADR 0405). No longer an image.
- **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` showed it. *Deploy* is used in this one place. Deleted at
release 6 of ADR 0429 (§8, ADR 0539): the word stays in use until that release is delivered, and goes to a
*Not:* line with the as-is update that follows the delivery (playbook 02).
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). Kept as two keys with a time to live on the bus; the first to
expire closes the batch (ADR 0407). Goes at release 6 of ADR 0429, when nothing collects merges (§12), and
its keys' bucket is deleted (ADR 0539 §13); the word goes to a *Not:* line with the as-is update after that
delivery.
expire closes the batch (ADR 0407).
- **batch** — the merges one merge window collected, planned once as one rollout. For a repository merged more
than once in it, the rollout takes the latest commit, and the earlier merges are answered by it. Not *group*,
which is a delivery group. Goes at release 6 of ADR 0429 (§11, §12): a merge moves the source revision and
nothing collects it; the word goes to a *Not:* line with the as-is update after that delivery.
- **mesh-delivery** — the repository of the delivery domain, holding its parts as modules prefixed
`mesh-delivery-` (ADR 0429 §2), made by the forge module from its config (ADR 0428). Until release 3 of
ADR 0429 also the module in the catalogue that owns deliveries and delivery groups, holding the mesh-scoped
seat of the same name: it records and decides, and the controller sends, judges and rolls back when it is
asked. Release 3 splits that module into the parts, and then the name means the repository alone.
- **part** — one independent process of the delivery domain with its own bus credential, which watches keys
and writes keys and never calls another part (ADR 0429 §1). Each key has one writer; the operator's
credential may also write a want, the one manual escape. The parts: the **forge bridge** (forge events in,
the only caller of the forge), the **indexer** (fingerprints, the candidates of a group, the modules of the
trunk), the **builder** (claims one fingerprint once and orders its build), the **checker** (the merge check on built
artifacts), the **gate** (merges a group and moves the source revision; *gate part* where it could be read
as another gate, see [Homonyms](#homonyms)), the **teller** (the operator's phone, and the only writer of an
ask's answer), the **assigner** (writes each node's wants by its seven rules, ADR 0539 §6), the **node
agent** (the node-engine's own process, applying its wants), the **watchman** (presence and pulses) and the
**status view** (`mesh-delivery-cli` and a web view, writing only its answers to asks and saying the events
`delivered`, `moving`, `settled` and `stuck`, derived from the keys). The reviewer is the review
agent's session, not a part. Built over releases 1 to 6 of ADR 0429.
- **fingerprint** — what identifies a module's build by its content: a hash over its source files, its
manifest and the fingerprints of its build-time inputs, so equal content gives an equal fingerprint whatever
the commit (ADR 0429 §5). It is today's source fingerprint with a base entering by its module's
fingerprint, and a build resolving packages by its lock; from R4g the builder is no input of any fingerprint (ADR 0530). From
release 4 a build's key, so one source is never built twice.
- **source revision** — one revision of the key `source.main`, written only by the gate: every repository's
merged head and the group that moved it. A group reaches the mesh as one source revision (ADR 0429 §11); a
source revision is **delivered** when every want equals its module's fingerprint and every have equals its
want and is healthy, derived from the keys and recorded by no part (§1).
- **canary** — the first node of a module's nodes, ordered by ring then name: a new fingerprint reaches it
first and the module's other nodes only once it runs healthy there (ADR 0429 §6). Replaces sending to every
machine at once from release 6 (§15); a fingerprint that fails there is held and the canary set back, or for
a module that keeps data the operator is asked (ADR 0539 §18, §19).
- **want** and **have** — a want (`want.<node>.<module>`) is the fingerprint a node should run of a module and
the digest of the one **composition** it allows for it, written by the assigner by its seven rules; the node's
own want `want.<node>.node` names its node composition (ADR 0429 §4, §6; ADR 0539 §1, §6). A want may also
carry the operator's answer to a failed backup (ADR 0539 §16). A have (`have.<node>.<module>`) is what the
node runs: the fingerprint, the composition's digest, its phase (`held` among them) and health, written by
that node's agent. A node's facts that no module owns are its node key. They replace rollouts at release 6,
and the assignments keep placement alone (ADR 0429 §17, ADR 0539 §12); haves are written from release 1.
- **pulse** — a key the watchman writes for each key whose next move a machine owes, with a time to live; its
expiry is a stall, raised as a condition, so no part needs a timer (ADR 0429 §1, research 063). A person's
wait carries none.
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.
- **rollout** — the controller's sending of one batch's builds across nodes, at most one trunk commit per
repository, tier by tier, to every node that runs each module at once (ADR 0276, ADR 0360). Rollouts that share a
repository, a module or a graph edge run one after another; others run side by side (ADR 0407). It names every
merge it answers, and its record is one key of the bus's rollouts bucket, a link of the chain (ADR 0408). A primitive the delivering stage
asks for, not an object anyone manages. A **tier** is one step of a rollout: a module, then the modules
built against it. The controller's verb `plans` lists the rollouts. A module is rolled out by its `roll`
policy. Rollouts and tiers go at release 6 of ADR 0429 (§8, §15), replaced by wants and haves (§17); the
rollouts bucket is kept read-only with its history (ADR 0539 §12), and both words go to a *Not:* line with
the as-is update after that delivery.
policy.
*Not:* ~~walk~~ (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). Goes at release 6 of ADR 0429, replaced by the
**canary**. Not *the gate* bare (see [Homonyms](#homonyms)).
back, no condition raised since the send (ADR 0236 §2). Not *the gate* bare (see [Homonyms](#homonyms)).
- **core module** and **leaf module** — a core module is one on the controller's own path (the controller,
the node-engine, the tool runner, the bus, mesh-delivery) or the mesh's resolver, whose judgement protects
the mesh's ability to reach a node at all; a leaf module is every other module. Decided by
@@ -620,9 +501,8 @@ controller (the planner, the rollout) until release 6 of ADR 0429; from each rel
part. Its **fix budget** is what it is held to: five minutes for a leaf module, ten for a core module, counted
from the move to `fixing`; an issue whose fixes move no module has none (ADR 0313). Not the delivery time,
which starts at the merge.
- **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`, until release 6
of ADR 0429, when the verb goes (ADR 0539 §17) and a held move goes on by the operator's answer to the
assigner's ask. Used in this sense only.
- **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
@@ -632,7 +512,7 @@ controller (the planner, the rollout) until release 6 of ADR 0429; from each rel
**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** Delivery (the first-node gate) and Operator and conversation (a condition
**Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition
becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283.
**Uses:** node, module, node-engine (Core).
@@ -674,7 +554,7 @@ becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283.
**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** Delivery and Health and repair. **Decided by** ADR 0232, 0233, 0235.
**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`,
@@ -721,10 +601,10 @@ it should not. **Recorded by** the controller. **Upstream of** Provisioning and
**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, Delivery, and Identity and access. An authorising
**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, 0383, 0384, 0385, 0390.
**Uses:** operator, person, node, module, seat, tool (Module), proof and identity (Identity and access), release (Delivery).
**Uses:** operator, person, node, module, seat, tool (Module), proof and identity (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`,
@@ -738,10 +618,10 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272, 0383, 03
line as the **controller's terminal** or as an ordinary call
(ADR 0272).
- **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*. *nox* is also the agent (ADR 0414 §7): see
[Homonyms](#homonyms), and say which is meant. It is the one command the operator types: every line the mesh addresses to the operator — a
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 the words test and the catalogue's check refuse
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
(ADR 0272,
its insight of 2026-10-09).
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
@@ -780,8 +660,8 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272, 0383, 03
- **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; to the mesh, an identity of kind agent
(ADR 0386). Not the node-engine, and not a
build module.
(ADR 0386). Not the node-engine, and not the
build seat's holder, whose seat name `node-build-agent` is an identifier to rename.
- **thread** — one line of agent work, owned by the mesh: its turns, tool calls and results, its workspace
and usage, kept whole for good on the bus by the thread seat; it outlives any session, node or front end
(ADR 0383, ADR 0384).
@@ -840,80 +720,11 @@ it was told, and the servarr apps' own libraries. **Upstream of** nothing. **Dec
- **grab** — an engine handing one media release to a download client. It names the work it is for, so
the media-importer knows where the copy belongs.
## Mail — the operator's own correspondence, which the mesh holds and reads for them
**Purpose.** To hold the operator's mail accounts, say each mail on the bus, find and read mail for whoever the
operator granted, note each mail for the operator and tell them of the mail that matters, and send a mail only on
the operator's Approve. **Recorded by** `mesh-mail` (its index database and attachment bucket, ADR 0359 §3; its
account buckets, ADR 0439) and `mesh-mail-reader` (its database of mail entries, mail groups and reading lessons,
ADR 0459; and its handling, action log and suggested actions, ADR 0517, ADR 0519). **Downstream of** Identity and access and Operator and conversation; **upstream of** nothing.
**Decided by** ADR 0359, 0361, 0378, 0439, 0440, 0458, 0459, 0461, 0462, 0472, 0515 to 0519.
**Uses:** operator, person, module, seat; grant, identity (Identity and access); ask, warrant, operator channel,
content rule, untrusted input (Operator and conversation). A *judgement* is one call of the model with no tools
and no session (ADR 0278), in its plain sense.
- **mail account** — one of the operator's mailboxes that `mesh-mail` holds, of a kind of adapter (`imap`,
`imap-oauth`, `graph`), named by its address (ADR 0359 §1).
- **`mail-store`** — the seat holding the mail accounts: `accounts`, `search`, `read`, `send`, `send_status` (ADR
0378 §2, ADR 0440 §7). Always the seat's name: the bare *store* is Core's.
- **mail event** — what `mesh-mail` publishes on its stream `MAIL` for a new, changed or removed mail: headers,
flags and attachment references, never a body (ADR 0359 §2).
- **mail send** — a mail the mesh sends, which goes out only on the operator's warrant for that one mail (ADR 0361
§2), with the states `mail_send_status` answers (ADR 0440 §7). Any caller `mesh-mail`'s `grants` names for that
account may ask for one (ADR 0472).
- **mail web client** — the module `mesh-mail-web`, holder of `mail-webclient`: the operator's mail in a browser on
the private network (ADR 0440 §1). The page it serves is the mail page.
- **mail notice** — `mesh-mail-notifier`'s desktop pop-up for a new inbox mail (ADR 0378 §7, §9). It retires when
`mesh-mail-notifier` is unassigned everywhere (ADR 0461 §7) and then moves to *mail signal*'s *Not:* line
(issue 1418).
- **mail reading** — the model's reading of each new mail as it arrives, in batches, into its mail group and mail
entry; the seat `mail-reading`, held by `mesh-mail-reader` (ADR 0459). Not *triage* (the issue tracker's
judgement), and not `mail_read`, `mail-store`'s tool that fetches one mail's text.
- **mail entry** — what mail reading keeps about one mail: its mail group, a one-line summary, its mark and why, a
due date; never the body (ADR 0459 §4). Not *catalogue entry*: the catalogue is Delivery's.
- **mark** — what a mail entry says the mail is to the operator: *matters*, *scam warning* or neither, never two
(ADR 0459 §7). Not *kind* (Media's).
- **mail group** — a set of mails the model put together because they belong to one subject (ADR 0459 §4). Not *delivery
group* or *account group*.
- **reading lesson** — one correction the operator made on the mail page (a mark or a mail group), kept so later
mail reading follows it (ADR 0459 §8 to §10). Always qualified: the bare *lesson* is Operator and conversation's.
- **mail signal** — the notice on the operator's channel, one `tell` per batch in words only, and the place at the
top of the mail page, that tells the operator mail matters (ADR 0461). Always qualified: the bare *signal* is
Health and repair's.
- **scam warning** — the mark of a mail the model judges an attempt to deceive the operator, with why; shown in
its own place on the mail page and never a mail signal (ADR 0459 §7, ADR 0461 §6).
- **mail conversation** — a thread (ADR 0383) with a mail context, followed on the mail page: the mail questions
and their answers, each answer with the mails it found. The mail context is the framing that a mail is data, the
tools `mail_search` and `mail_read` only, the operator's senders and folders, and the reading lessons (ADR 0462).
- **mail question** — what the operator writes in the mail conversation to find or understand mail. Not *ask*
(Operator and conversation's request for the operator's input).
- **the operator's list** — what the mail page opens on: the tasks by their due date, the promises, *good to know*,
*read later* and *handled today*, for one mode (ADR 0515 §1). Always *the operator's list*: the bare *list* is
the classical list of mail, shown as *All mail*.
- **task** — what a mail asks the operator to do, with a due date or none, written by mail reading in its own words
(ADR 0516 §1).
- **promise** — what a mail says someone will do: *you promised*, *waiting on others* or *on its way*, with who and
when (ADR 0516 §1).
- **read later** — the flag of a mail worth reading that asks nothing, which a mail of any mark may carry (ADR
0516 §1). Not a *mark*.
- **handled** — what the operator marked done on the operator's list; it changes nothing in the mailbox (ADR 0517
§1).
- **action log** — mail reading's record of every act of the operator on the mail page, never mail text, kept a
year (ADR 0517 §3).
- **Owly** — the mail page's assistant: it answers mail questions through mail reading, gives the morning briefing,
and suggests actions; it sends and changes nothing by itself (ADR 0515 §2, ADR 0518).
- **morning briefing** — Owly's account of the day, asked the first time the mail page opens each day (ADR 0518).
- **suggested action** — an *archive*, *report as phishing* or *unsubscribe* that mail reading puts forward for the
operator's Approve on the mail page (ADR 0519 §1). Not *proposal* (Operator and conversation's, which takes effect
on a warrant).
- **approval inbox** — the place on the mail page where the suggested actions wait, each with *Approve* and *Not
now* (ADR 0519 §4).
## 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, the issue tracker and the knowledge base. **Decided by** ADR 0019, 0080, 0244, 0275,
0284, 0290, 0291, 0423, 0424.
0284, 0290, 0291.
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation, held whole in the
knowledge base and named "research NNN";
@@ -927,27 +738,14 @@ Not a domain of the mesh but of the way it is built, listed because its words me
(ADR 0290). Not a *rule*, which is a line of
`00-META/how-we-build.md`.
- **knowledge base** — what holds the decision records and research efforts whole (number, status, topic,
typed links, clauses and reasoning, with every version), and, once they are moved, the designs and the glossary
entries too (ADR 0423); takes them as proposals, has them reviewed and accepted,
typed links, clauses and reasoning, with every version), takes them as proposals, has them reviewed and accepted,
and answers what holds: the seat `knowledge-base`, held by the module `mesh-knowledge`
([55 — The knowledge base](../03-DESIGN/01-to-be/55-the-knowledge-base.md)). Not **the record**, the literal reader
of this repository.
- **design** — a record of the knowledge base, named "design NN", with a **to-be** section (what is being built)
and an **as-is** section (what runs), its owning repositories and its links to its decisions and other designs
(ADR 0423). Until the move, a document in `03-DESIGN/`, in `01-to-be/` or `00-as-is/`.
- **design state** — of a design: proposed, designed, in progress or implemented, worked out by the knowledge base
from the issues with a design link to it and whether their deliveries are done; never written by anyone
(ADR 0424).
- **progress** — of a design, a milestone, an issue or a record: how far it has come, each part done, moving,
waiting or stuck with when it last moved, worked out by the knowledge base's verb `progress` from the issue tracker
and the deliveries and never written by anyone; `minimal` says one line per part, `detailed` the tree of a lead's
report (issue 1947).
- **glossary entry** — one entry of the glossary as a record of the knowledge base: its head word or words, its
domain, its meaning, its *Not:* line and its identifier line (ADR 0423). Until the move, an entry of this page.
- **milestone** — a group of a design's issues delivered together, as one delivery group, or one pull request when
it touches one repository; named by what the operator or the mesh can do once it is delivered (playbook 04,
ADR 0401).
*Not:* ~~epic~~ (hq)
- **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built).
- **milestone** — one step of a design's work breakdown, usable on its own once delivered and named by what the
operator or the mesh can then do; delivered as one feature (playbook 04, ADR 0401).
- **epic** — a run of milestones in one design, released milestone by milestone (ADR 0401).
- **issue** — a question about the mesh that someone must answer, numbered and tracked by the issue tracker, which
holds it whole: its report, diagnosis and resolution included (ADR 0291);
the folders in `04-ISSUES/` are imported there and removed.
@@ -977,39 +775,34 @@ Not a domain of the mesh but of the way it is built, listed because its words me
- **tracks** — of an issue and a condition: the issue links the condition's key with a link of kind `condition`. The
**tracking line** is the line the router says in every message about a condition: the issues that track it and
where each stands, or that none does (ADR 0285).
- **hq check** — one of the Go tests in `00-META/checks/`, run by `go test ./...` and by the checker as `mesh/check`.
- **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 the words test:** a word list cannot tell one
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, until release 6 | that node's **declaration** (verb `plan`) | Placement |
| | what a delivery would do to the mesh, until release 6 | **delivery plan** | Delivery |
| | the sending of one batch of commits across nodes, until release 6 | **rollout** (verb `plans`) | Delivery |
| 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 | **rollout** (verb `plans`) | Change and delivery |
| | a step a person starts, like the bus's | **planned step** | Core |
| composition | one module's resources for one node at one fingerprint, signed, from release 6 | **composition** | Placement |
| | the function in mesh-sdk that composes them, which the controller and the assigner both run | **mesh-sdk's composition** | Placement |
| | whether the machines a change touches still compose | **composition check** | Delivery |
| 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, until release 6 | **send** (verb `push`) | 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** | Delivery |
| | one milestone built, checked and delivered as one (playbook 07; releases 1 to 6 of ADR 0429) | **release** of a milestone | The record |
| 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 |
| | a copy of a work an engine can fetch | **media release** | Media |
| gate | the judgement on a delivery's first node | **first-node gate** | Delivery |
| | the check of what a change makes of the machines it touches, a pull request status until release 4 of ADR 0429 | ~~merge gate~~ (hq); the **composition check** | Delivery |
| | the part that merges a group and moves the source revision | **gate part** (`mesh-delivery-gate`) | Delivery |
| gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery |
| | the pull request status | **merge gate** | Change and delivery |
| | 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 rollout | **tier** | Delivery |
| | a step of a rollout | **tier** | Change and delivery |
| | how much proof an ask needs | **assurance level** | Identity and access |
| harness | the part of `mesh-sdk` that serves a tools bundle | **SDK harness** | Module |
| | the program that runs an agent's sessions | **agent harness** | Operator and conversation |
@@ -1017,54 +810,44 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| role | what a seat names, at a scope | **seat**; the bare word in older designs is tracked by issue 872 | Module |
| | what an identity may call | **role** | Identity and access |
| ask | a request for the operator's input | **ask** | Operator and conversation |
| | an entry in the build queue | **build request** (an ask on a build stream) | Delivery |
| | an entry in the build queue | **build request** | Change and delivery |
| store | the one database server | **store** | Core |
| | the OCI registry | **artifact store** | Delivery |
| | the OCI registry | **artifact store** | Change and delivery |
| | a node's own copy of its last declaration | **last declaration** | Placement |
| | the seat holding the mail accounts | **`mail-store`** | Mail |
| record | a numbered decision | **decision record** | The record |
| | the literal reader of this repository, searched first for a symptom | **the record** (the `records` module) | Operator and conversation |
| | the upgrade policy that sends nowhere | `record` | Module |
| nox | the mesh's one agent identity, its logins and accounts (ADR 0414) | **`nox`, the agent** | Identity and access |
| | the operator's terminal command, `mesh-cli` | **the `nox` command** | Operator and conversation |
| knowledge base | the module holding the decision records and research efforts whole | **knowledge base** | The record |
| | the predecessor's operational memory, in older playbooks (issue 430) | none: it no longer exists | — |
| check | a pull request's two statuses, until release 4 of ADR 0429 | **merge check**, **repository check**; from release 4, `mesh/check` | Delivery |
| | whether the machines a change touches still compose, the assigner's | **composition check** | Delivery |
| | a delivery group's verdict | **composed check** | Delivery |
| check | a pull request's two statuses | **merge check**, **merge gate**, **repository check** | Change and delivery |
| | a delivery group's verdict | **composed check** | Change and delivery |
| | 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 holder of the seat `node-build-agent` until R4g; from release 4 of ADR 0429 the part `mesh-delivery-builder` | **builder** (the part); **build module** (what builds) | Delivery |
| | 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** | Delivery |
| deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery |
| | a wrapped program's own deploy | its own word | — |
| managed | a placed file of which only named keys or a marked block are the mesh's | **managed** (`owner-at-runtime`) | Provisioning |
| | a file the mesh writes, such as the agent's instruction file or `managed_settings` | the file's own name, or *a file the mesh places* | — |
| trusted | a config value that decides what runs or who can reach what | **trusted** config value | Config |
| | a sender on the operator's own list | **trusted** sender, its message an **operator message** | Operator and conversation |
| lesson | an agent's proposal after the operator corrects its way of working | **lesson** | Operator and conversation |
| | the operator's correction of mail reading | **reading lesson** | Mail |
| signal | something that must keep happening | **signal** | Health and repair |
| | the notice that mail matters | **mail signal** | Mail |
| conversation | the domain *Operator and conversation* | the domain's name | Operator and conversation |
| | the mail questions and answers on the mail page | **mail conversation** | Mail |
## 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 the words test then fails on it in running prose. A
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
decision record keeps whatever word it was written with — those are immutable — so a word
retired here may still appear in one, and the *Not:* lines are how to read it.
**How it is checked** (ADR 0244):
The words test in `00-META/checks/` (`go test ./...`, run by the checker as `mesh/check` on every pull request) fails on a retired
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
issues opened 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)*
-16
View File
@@ -173,22 +173,6 @@ quietly stop being true, and nobody will learn that from a document.
Not test-driven development as a blanket rule — a test written first against undiscovered
behaviour asserts a guess. The obligation is that every decision has a defender.
### A bus permission is proven under the real permission
A change that adds or widens what a mesh process reads or writes on the bus (a subscription, a publish, a stream or
key-value read, a consumer) carries an integration test that runs it on a bus user with exactly the production
permissions of that account (mesh-sdk's composition of them), against a real bus server, and that fails with the
bus's permissions violation when the widest new access is not granted. It runs in the forge's check like every
integration test. A reviewer reading code does not see a grant, and a fake bus grants everything (ADR 0560).
How it is checked: a review that approves such a change whose pull request names no such test is the rule skipped.
### A bus permission under the real permission — how it was earned
On 2026-10-27 a change widened what every node agent watches; two reviews read its code and its tests ran against a
fake bus. Live, every node's agent was refused that read by its grant and stopped composing, and the same day the
grants part's user list dropped nine seats' subscriptions (issues 1126 and 1945).
### Tests run at the lowest level that proves them, within a time budget
Three levels, by how much real system a test uses (ADR 0391):
+4 -8
View File
@@ -48,9 +48,6 @@ the expensive half.
## The playbooks
A drawn overview of them, the cycle, a feature's steps, the review loop and an issue's life, is
[`playbooks.html`](playbooks.html): a picture of these playbooks, never their authority.
| # | Playbook | Trigger |
|---|---|---|
| [01](01-research.md) | Research | An idea from the operator, or one worth investigating before committing to design: setup questions, findings, a direction the operator confirms |
@@ -60,18 +57,17 @@ A drawn overview of them, the cycle, a feature's steps, the review loop and an i
| [05](05-constitution-sync.md) | Constitution sync | `how-we-build.md` changed a rule the mesh enforces |
| [06](06-writing-a-module.md) | Writing a module | Something that runs today must run on the mesh |
| [07](07-feature-branches.md) | Features | Work that changes code across issues or repositories: prepared context, local development, one review, one check, one delivery |
| [08](08-small-fixes.md) | Small fixes | One issue in one repository, about an hour of work: one review, one check, one delivery |
| [08](08-small-fixes.md) | Small fixes | One issue in one repository, about an hour of work: one review, one push, one delivery |
| [09](09-cleanup.md) | Cleanup | Merged branches, stale pull requests, finished worktrees and folders: read first, delete only what the operator approves, report the rest |
| [10](10-visual-design.md) | Visual design | A page the operator will use is about to be designed or rebuilt: mockups with fixed data, the operator picks, the pick is the reference |
## The cycle is checked
The flow above is a rule, and a rule states how it is checked:
the cycle test in [`00-META/checks/`](../checks/) refuses a to-be design that names no
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
decision, an `in-progress`/`implemented` design that names no owning code, two issue folders
sharing a number or an issue report carrying a status key; a `graduated` research effort that
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with the records, index and
words tests before any HQ merge: `go test ./...` at the root runs all four, and the checker runs the same as `mesh/check` (ADR 0527). The issue rules the check
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with `records.py`, `index.py` and
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). The issue rules the check
once held — `located` with an owner, `resolved` with a fix, a core issue with its replay — are
refused at the write by the issue tracker's `move`
(ADR 0275). What the checks cannot see — that code work actually started from a
+2 -2
View File
@@ -119,7 +119,7 @@ for the operator's Approve on a channel that proves who answered.
- Do not put status in prose. It is the effort's `status`, and the prose must not restate it.
- Do not give a `became` link while the effort is open — playbook 02 gives it at closure.
- Do not write into `03-DESIGN` from an open effort.
- Do not write an effort, or a copy of one, into this repository: the records test refuses a new path under
- Do not write an effort, or a copy of one, into this repository: `records.py` refuses a new path under
`01-RESEARCH/`.
## Closing
@@ -136,5 +136,5 @@ after every acceptance and raise a condition when one fails. The habits of steps
against the rules, one version per settled set, the cheapest level, nothing left open) are the lead's, read by
the reviewer; of the pre-check, `propose` refuses a link to nothing and a retired word, and the review returns
a record named but not linked, an untracked deferral and a figure not as measured. That this repository holds
no effort is the records test's `no-records` check, on every pull request. An active effort with no new version
no effort is `records.py`'s `no-records` check, on every pull request. An active effort with no new version
for seven days is the smell of research left behind: its lead closes or continues it.
+4 -9
View File
@@ -94,10 +94,7 @@ about an hour, and said to the operator if it runs past.
deferral no issue tracks. The knowledge base then reviews the proposal itself, through a judgement that
sees only the record, the records it links and the rules, and answers `approve` or `changes`. On
`changes` the record stays proposed with the findings: revise it by proposing it again, whole, with `why`;
after three rounds the lead does not stop to ask, but fixes each further finding itself and proposes again
(the operator, 2026-10-18: ADR 0429 went past three rounds, each finding narrower than the last, and asking
first only added a wait). Only a finding that would change what was decided, rather than how the record
says it, goes to the operator, with a recommendation.
at most three rounds, then the findings go to the operator with a recommendation.
On `approve` it is accepted, or, when it says `needs-operator`, it waits for the operator's Approve on a
channel that proves who answered. Nothing proposed is in force. `knowledge-base.get` shows a record's
reviews and the operator's ask when it waits for one.
@@ -153,7 +150,7 @@ Implementation state is a third axis, independent of both design and decision.
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
the decision stands, is a progressive insight — marked and dated in the record, never silent.
- Do not write a decision record, or a copy of one, into this repository: the records test refuses a new path
- Do not write a decision record, or a copy of one, into this repository: `records.py` refuses a new path
under `02-DECISIONS/` other than its README.
## How it is checked
@@ -162,10 +159,8 @@ The rules on a record are the knowledge base's: `propose` refuses one that break
record (its module's table test holds one case per rule), and the four rules that judge the whole record set
(an accepted decision cited by nothing, a supersession named by one side only, a changed clause nothing
changes, an accepted record extending a superseded one) run after every acceptance and raise a condition
when one fails. In this repository, the records test checks that a design's `decisions:` entries and every
"ADR NNNN" in the text are well formed, and that no record is written here; the index test checks the reading
when one fails. In this repository, `records.py` checks that a design's `decisions:` entries and every
"ADR NNNN" in the text are well formed, and that no record is written here; `index.py` checks the reading
order's six topics, which the knowledge base reads. The three habits of graduating step 2 (side by side, clauses
from the direction, the pre-check) are the lead's, read by the reviewer; `propose` refuses a link to nothing,
a deferral no issue tracks and a retired word, and the review returns a record named but not linked.
The rounds are read in `knowledge-base.get` (its reviews, one per version): a question to the operator about a
record's wording after its third round is the failure of step 4 of *Writing a decision record*.
+3 -3
View File
@@ -66,7 +66,7 @@ import read, which every imported issue names (`imported.commit` in `issue-track
```
Then `# NNN. The title`, the title as the tracker holds it, and the symptom as observed, as in the tracker's
report. **No status key**: the cycle test refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
report. **No status key**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
`replay:` or `replay-none:` in any report, because the tracker holds them and two places holding one fact
drift.
@@ -107,7 +107,7 @@ import read, which every imported issue names (`imported.commit` in `issue-track
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 the cycle test refuses two records
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records
sharing a number.
- An issue whose answer is a general lesson should also have that lesson written where a search by
the symptom's literal words finds it, so the next person searching it finds it. Both, not either.
@@ -117,6 +117,6 @@ import read, which every imported issue names (`imported.commit` in `issue-track
- **A rule states how it is checked.** The cycle's rules on an issue — a status one of the six, `diagnosing` and
`fixing` with a worker and no other current one, `located` with an owner, `resolved` only from `fixing` and with a
fix, a core issue with its replay, `wontfix` with a reason — are refused at the write by
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). the cycle test checks what
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). `cycle.py` checks what
this repository still holds: no two folders share a number, every report has frontmatter with the day it was
`opened:`, and no report carries a status key.
+10 -22
View File
@@ -9,19 +9,11 @@ an hour, and said to the operator if it runs past.
A design becomes **milestones**, and each milestone becomes **issues**:
- **A milestone is usable on its own.** Once it is delivered, the operator or the mesh can do
- **A milestone is usable on its own.** Once its delivery plan is done, the operator or the mesh can do
something new, said in one sentence ("a Claude Code session is mirrored into a thread on the bus").
A milestone that changes nothing anyone can use is not a milestone; fold it into the next.
- **A milestone is one release** of playbook [07](07-feature-branches.md): prepared, developed whole, reviewed,
checked and delivered as one, never cut for its size or for time (the operator, 2026-10-17).
- **A milestone is one piece of functionality, never cut by an order between its parts** (the operator, 2026-10-21:
a milestone may span several modules, repositories and dependencies such as an SDK change; 2026-10-22: "a milestone
is a milestone, we shouldn't cut it"). Which of its modules reaches the machines first (a reader of a new manifest
word before the modules that use it, a provider before its consumers) is the assigner's rules' (design 47); an SDK
change is built into its users. The parts such an order touches are found in playbook [07](07-feature-branches.md)
step 0 item 6 and become issues of the same milestone; an order those rules cannot keep is an issue of the build
and delivery system, not a cut.
Until 2026-10-22 this rule cut milestones by release order (design 47's release 1-2 became six milestones).
- **A milestone is one feature step** of playbook [07](07-feature-branches.md): prepared, developed in one to
two hours, reviewed, checked and delivered as one. A milestone larger than that is split.
- **An issue is one repository's part of a milestone,** sized so a fixing agent finishes it inside the
milestone's development time. Issues that must land in order are linked `depends-on`.
@@ -38,9 +30,8 @@ A design becomes **milestones**, and each milestone becomes **issues**:
4. **Break it into milestones.** Write them into the design, in a section *Milestones*: for each, its number,
its one-sentence outcome, the acceptance tests of the design it proves, the repositories it touches, and
what it depends on. Order them so each is usable as soon as it is delivered, the smallest useful one
first. A milestone is named by what it lets someone do; how it ships is shaped by the modules its changes move
and the nodes that run them (ADR 0401 §6), read from the indexer's fingerprints through the status view's
`indexer_modules` and `indexer_candidates`, and from the nodes' haves (`node_haves`).
first. A milestone is named by what it lets someone do; how it ships is shaped by the plan its changes would
have, asked through `mesh-delivery.what-if`: the modules it moves and their fan-out (ADR 0401 §6).
5. **Ask the operator to confirm the milestones**, in a short table: number, outcome, the acceptance tests it
proves, repositories, the parts each repository builds. Change what they change.
6. **Open the issues.** For each confirmed milestone, `issue-tracker.open` one issue per repository part, its
@@ -49,10 +40,8 @@ A design becomes **milestones**, and each milestone becomes **issues**:
entry in the design.
7. **Flip the status.** `status: in-progress`, `updated:` today; the design change goes through playbook
[08](08-small-fixes.md) (one hq pull request).
8. **Hand each milestone to playbook 07, one at a time.** The milestone is one release of playbook 07; its issues
are the release's issues; the next milestone starts when the previous one is delivered. A design waits
while another design's milestones are being built: one design is built at a time (playbook
[07](07-feature-branches.md)), so this step starts only when the design before it is delivered.
8. **Hand each milestone to playbook 07, one at a time.** The milestone is the feature step; its issues are the
step's issues; the next milestone starts when the previous one's delivery plan is done.
9. **On completion of the last milestone**, run the "when something ships" section of playbook
[02](02-graduation.md).
@@ -63,17 +52,16 @@ A design becomes **milestones**, and each milestone becomes **issues**:
owns what is not. The knowledge base's rule `deferral` checks the decision record when it is proposed
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)).
- HQ is not a code repository and never carries implementation.
- How the building is done — worktrees, agents, review, checks, the Approve and the delivery — is playbook
- How the building is done — worktrees, agents, review, checks, the ready mark and the delivery — is playbook
07's, not this one's.
## How it is checked
Read by the lead and the reviewer today; a check among hq's Go tests is tracked by issue 845.
Read by the lead and the reviewer today; a check in hq's merge-check is tracked by issue 845.
- A design in `status: in-progress` has a *Milestones* section, each milestone with its outcome and its
issues; an in-progress design without one is the smell of building without a breakdown.
- Every issue a milestone names exists in the tracker (`issue-tracker.get`), and an issue in `fixing` names
its milestone in its report.
- A milestone whose issues are all `resolved` is delivered (the status view said `delivered` for its source
revision); its outcome sentence and
- A milestone whose issues are all `resolved` has had its delivery plan done; its outcome sentence and
acceptance tests are proven on the mesh before the next milestone starts.
-20
View File
@@ -105,23 +105,3 @@ time and the scaffolding was not.**
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
- **A data directory is never removed by the mesh** (ADR 0030),
and that protects against the mesh only — not against a disk or a mistaken command.
## Moving a module's directory
When a fix moves what a container mounts to a new path (issue 1843: only-office's working directories
moved into a root-only box), the old directory does not go away by itself, and it is closed in three steps.
1. **The move.** The new directory gets a new id at its new path. The old one keeps **its old id at its old
path** for as long as it holds data, with the mode it should now have: the node-engine leaves a directory
it finds at a path under a new id as it found it, and sets no mode when the owner differs (issue 339), so
only the old id closes it. The node-engine removes a directory that is no longer declared only when it is
empty; a non-empty one is kept on disk with no further cleanup.
2. **The deletion, on the operator's word.** Its contents are data, so they are removed only after the
operator's yes, through `<node>/node-files.delete`. That tool moves into the node's trash: the disk space
comes back only when the trash is emptied (issue 1870: no tool does that yet).
3. **The declaration leaves** in a second small fix, once the directory is empty (issue 1854).
Plan the three in the first fix's issue, so the second fix is not found later by accident.
A service that starts on empty working directories may need minutes before it answers (OnlyOffice: about
two): the live check after the delivery waits for it before calling the move broken.
+95 -491
View File
@@ -19,7 +19,7 @@ A small fix (one repository, one issue, about an hour of work) follows
minutes took forty-one.
- **Finished work that never ships.** A feature that kept taking new issues held thirteen landed issues
undelivered for a day: an issue done at 21:09 was still not on the machines at 15:00 the next day.
- **Work that waits and nobody notices.** Most lost time was not work at all, but a check, a delivery
- **Work that waits and nobody notices.** Most lost time was not work at all, but a check, a delivery plan
or an agent waiting on something that had stopped, unseen.
- **Review that keeps hardening.** One fix went through seven review rounds in five and a half hours, each
asking for more hardening than its issue asked (issue 704, 2026-10-14). A round is for what makes the
@@ -28,199 +28,73 @@ A small fix (one repository, one issue, about an hour of work) follows
hours of local work, met its first check on 2026-10-16 and failed on 29 problems, TypeScript errors among
them, that a unit suite run continuously while working finds at once.
- **A green pull request nobody marks.** An approved, green pull request that was not marked ready waited
12 h 17 min for someone to merge it (mesh-catalog #300, 2026-10-14). There is no ready mark since ADR 0421:
the operator's Approve is asked as soon as the checks pass.
- **Review rounds spent on what a run or a search would have shown.** On 2026-10-19 milestone R3b of design 47
took two review rounds and one more cycle after a failed try. A test that "failed first" only by not compiling
proved nothing, and dropping the field it was for passed every test. A reader of the changed record that the
survey never listed ended a module's conditions at each sync. The reviewer judged the bus tests by reasoning, and
wrongly, because they ran only after the review.
- **A check asked again and again.** Build capacity is the mesh's scarcest resource ("we don't have more
processing power", the operator, 2026-10-26), and a release that ran a try before its review, a try after it
and a check per fix round on an open pull request spent it several times over: every push to an open pull
request asks a new check, of every member for a group. A release runs one check ("every release should only
run 1 check or 1 try", the operator, 2026-10-26; ADR 0548).
- **A node agent's bus reads, proven only by review.** On 2026-10-27 a change widened what every node agent watches
on the bus; two reviews read the code, no test ran it under a node's real permissions, and once it was live every
node's agent was refused that read and stopped composing (issue 1126). How-we-build's rule "A bus permission is
proven under the real permission" (ADR 0560) is what the review now asks for.
12 h 17 min for someone to merge it (mesh-catalog #300, 2026-10-14).
- **A module proven only after it reached the machines.** The mail modules were delivered with unit tests
only, then failed live three times (issues 859, 865, 868) and took five more hours of fixes.
## The shape
One feature is **one branch name**, **one worktree per repository**, **one pull request per repository**,
opened **once**, after the review, and **one check** (ADR 0548). It runs in five steps, and its time is bounded. A milestone has no phases
(the operator, 2026-10-17): these are its steps, and a report names the step it is in:
opened **once**, after the review. It runs in five phases, and its time is bounded:
| Step | What happens | Bound |
| Phase | What happens | Bound |
|---|---|---|
| 0. Prepare | the lead settles the open questions and writes one context file per repository | 15 minutes |
| 1. Develop | one fixing agent per repository builds every issue locally, the whole unit suite before every commit, the integration tests compiled; nothing is pushed | the milestone's issues, 30 minutes each |
| 2. Review | one reviewer reads all of it, the local commits, judging the integration tests by reading them against the code; reports go both ways; at most 3 rounds | 15 minutes |
| 3. Check | push the reviewed heads once and open the pull requests together; the checker's one check runs the integration tests; no further review unless the check fails | one check run |
| 4. Deliver | merge; the source revision moves, each module is built once and wanted on its nodes, its canary first | until the view says the source revision `delivered` |
| 1. Develop | one fixing agent per repository builds every issue locally, unit tests only | 1 to 2 hours |
| 2. Review | one reviewer reads all of it; reports go both ways; at most 3 rounds | 15 minutes |
| 3. Check | push; the forge's merge checks run the integration tests; no further review | one check run |
| 4. Deliver | merge; the delivery plan builds, publishes and sends the whole feature | its delivery plan |
**One milestone is one release** (the operator, 2026-10-17). A milestone is developed whole, reviewed once,
pushed once, checked once and delivered once. It is never cut into steps for its size or for time: every cut adds a review, a
merge check, an Approve and a delivery of waiting, so the fewest cuts is the fastest. Nor is it cut for an
order between its parts (the operator, 2026-10-22: "a milestone is a milestone, we shouldn't cut it"): which module
reaches the machines before which is the assigner's rules' (a build and a composition before the want, a provider
before its consumers, the canary before the rest; design 47), and that is what they are for. An order they cannot
keep is a fault of the build and delivery system, opened as an issue there, never a cut and never a step a session
takes by hand.
A milestone is one piece of functionality, possibly spanning several modules, repositories and dependencies such as
an SDK change (the operator, 2026-10-21). Its pull requests in every repository share the milestone's branch name,
so they are one delivery group (ADR 0482, which holds that a change across repositories is one group, one plan and
one Approve, and that its developer orders no release). Nothing of the milestone merges until every issue of it is
built, reviewed and checked; then the one Approve releases it all as one source revision, the assigner's rules
keep a provider before its consumers, and an SDK change is built into the modules that use it. A small fix found during the milestone rides in this
group, as playbook [08](08-small-fixes.md) says.
A feature that cannot be developed in about two hours is split into **steps**. Each step runs all five
phases on its own; the next step starts only when the previous step's delivery plan is done. A step is the
unit that ships, so nothing finished waits for work that is not.
**One design is built at a time** (the operator, 2026-10-17). While the milestones of one design are being
built, no milestone of another design starts: its issues stay `open`, and nobody moves them to `diagnosing` or
`fixing`. The next design starts when the last milestone of the one being built is delivered. A
lead who finds another design's milestone already in `fixing` asks its owner before starting its own. A small
fix ([playbook 08](08-small-fixes.md)) and an issue that is in no design's milestones are not held by this rule.
**Scope is fixed at phase 0.** An issue found during a feature goes to the next step or the next feature,
never into the running one, unless it blocks the running one.
**Scope is fixed at the prepare step.** An issue found during a feature never goes into the running milestone, unless it
blocks the running one: it is fixed in between or right after the delivery, as the next paragraph says, or goes to the
next milestone or the next feature.
## Phase 0 — Prepare (the lead, larger model)
**An issue opened along the way is fixed by the session that opened it** (the operator, 2026-10-21): it is never only
listed under "Opened along the way" and left. A small one (one repository, about an hour) is fixed in between, as a
small fix ([playbook 08](08-small-fixes.md)) while the milestone waits on a check, an Approve or a delivery; the
rest are fixed right after the milestone's delivery, before the next milestone starts, together on one branch where
they share a repository. Either way it stays out of the running milestone's branch and pull request, unless it blocks
the running milestone, so the scope above holds. Only an issue in another design's milestones (one design is built at
a time), or one that needs a decision or the operator first, waits, and the report says which and why. Each report's line "Opened along the way" says for every
issue whether it is being fixed in between, after the delivery, or waits, and on what.
## Step 0 — Prepare (the lead, larger model)
1. **Name the feature once:** `feat/<slug>`, the same branch name in every repository it touches (but see the
exception for a new manifest word in phase 3: a release that must merge first has a name of its own). The
branch is never protected. **One milestone is one branch per repository** (the operator, 2026-10-17), with one
folder `.work/<slug>/`; never a second name for part of it (`-2`, `-step2`).
1. **Name the feature once:** `feat/<slug>`, the same branch name in every repository it touches. The
branch is never protected.
2. **Make the worktrees:** one per touched repository under `.work/<slug>/<repo>`, branched off `main`
(`git worktree add .work/<slug>/<repo> -b feat/<slug> origin/main`). The lab beds find the other
repositories by sibling path, so add the untouched siblings the lab reads as detached worktrees on
`main` (`git worktree add --detach .work/<slug>/mesh-tools main`), never symlinks. **A repository's own
clone stays on `main`** (the operator, 2026-10-17): no work is done in it and no branch is checked out
there, so a session that opens in it reads what holds now; fetch and fast-forward it before reading.
`main` (`git worktree add --detach .work/<slug>/mesh-tools main`), never symlinks.
3. **Settle every open question** in the feature's issues before any agent starts: on the issue, through
the knowledge base, or by asking the operator. **Read what is decided before designing** (`knowledge-base.ask`):
a question about delivery, checks or builds is most likely answered in design 47 and its decisions already (the operator,
2026-10-26: "that is simply our delivery plan and milestones, we've already researched it"); propose new research
only for what no record decides. A fixing agent that meets an undecided design question
the knowledge base, or by asking the operator. A fixing agent that meets an undecided design question
stops, and its run is spent again.
4. **Write one context file per repository**, `.work/<slug>/<repo>.context.md`. It is what a fixing agent
reads instead of rediscovering the code, and it is reused by every agent and every round:
- the issues it builds, in order, each with: the files and functions it changes, the decisions it builds
with (quoted, with where each was decided), and its acceptance criteria (item 5), each with the test that
proves it;
with (quoted, with where each was decided), the test to write first, done-when in one sentence;
- the repository's map for this feature: the packages and files involved, with one line each on what they
hold, and the patterns to copy (file:line);
- what not to touch, and what lands beside it in other repositories;
- the local facts: the worktree, the branch, the check commands, a temporary directory for tests outside
the home (`TMPDIR`), and the unit tests to run (never a database server: tests that need one are
integration tests, which the pull request's one check runs).
integration tests, which the forge's checks run).
When the reading is large, a read-only agent on the smaller model answers the lead's exact questions with
file:line, and the lead writes the file from its answers.
5. **Write the findings into the issues** (the operator, 2026-10-17: the better the content of the issues, the
better work the agents do, and the less review is needed). Before any agent starts, each issue carries, with
`issue-tracker.note`: every file and test that touches the change, found by search and not from memory
(file:line); **every other reader and writer of each value the change adds or changes** (a field, a key, a kind
of record), each with what it does with the new value, and a criterion for each that could treat it wrongly
(2026-10-19: the router's sync with the controller ended every condition the controller did not list, and the
survey, which followed only the new condition's own path, never named it); how the change is checked and delivered end to end (which test proves its done-when, which
modules its merge moves, read from the indexer's candidates of its group, and to which machines); and for a
group, its merge order. The context file
points to these notes; an issue reread by another session or a later agent then holds what was found.
When the reading is large, it starts with a read-only survey agent on the smaller model.
**Each issue carries its acceptance criteria, written by the lead** (the operator, 2026-10-18: the larger
model writes them, and they guide every test). Each criterion is one sentence, given, when, then, about what
the module or the operator observes, never about how the code does it, and names the level of the test that
proves it: unit, integration (a bus, a store or a process started) or end to end (none exist yet, so such a
criterion is proven live after the delivery and said so). Together they cover the issue's done-when and the
cases its life passes through: on 2026-10-18 mesh-delivery's group life (a group of three, a member's own
check, a close in a group, a second pull request at the same commit, a stop and a resend) had no criterion,
and each case was found by a failure on the live mesh. Each criterion is numbered in its note and cited as
`<issue>/AC<n>` (`1118/AC2`). They are an `issue-tracker.note` headed `Acceptance criteria`, a revision a new note that replaces the older one, until the tracker holds them as a
field (issue 1118). A criterion the reviewer adds in step 2 is written onto the issue the same way.
**The survey sizes each issue, and an issue sized over an hour is split before any agent starts** (the operator,
2026-10-17), into issues of about 30 minutes each, one change each, in the order they land: the bound of a
fixing agent's issue is then a real bound. On 2026-10-17 one issue held four changes sized at four to six
hours; it ran past its 30-minute bound for over an hour, and the reports could only say "still on part D".
6. **List every part the change reaches, before any agent starts**, so each is an issue of the milestone and
nothing is found on the way. Read, for each repository of the milestone, what it takes from the others and how:
the SDK's users (they pin an SDK commit, and a pin to a commit no branch holds is a download that fails later,
issue 1148); every program that parses a manifest strictly (search `ParseManifest` across every repository, with
the SDK commit each pins), which must take an SDK that knows a new manifest word (2026-10-21: design 60's
catalogue milestone found mesh-build-go and the delivery's indexer reading manifests with an SDK from before the
word); the SDK's list of a defined seat's verbs, which must name a verb a module adds (2026-10-21: node-files' two
new verbs were refused on the claim); the node-engine's decoder for a new key on the wire; the route by which a
repository's first module registers; and a part that needs something the bus or a manifest cannot yet give, which
needs its decision first. Each such part is an issue of the milestone and its pull request a member of the group;
the order in which they reach the machines is the assigner's rules' (the operator, 2026-10-22), never a cut.
Where those rules cannot keep an order (a node told of a key before it reads it, a module using a word before its
readers are delivered), that is an issue of the build and delivery system, opened with `issue-tracker.open` and
said to the operator.
7. **Write fixtures from the shape the other side writes, never from memory** (2026-10-18: a watchman's tests
mirrored a build key the controller never writes, and every test passed): copy a value from the writer's code or
from the live bus, and name where it came from.
8. **Answer three questions in the milestone file** (`.work/<slug>/milestone.md`; research 071, issue 1941), written by the
lead from this template before any agent starts. Each answer is a fact found, not a hope:
```
Lifetime of what the work creates (research 071, a):
- <bucket key or stream> | written by <module> | deleted by <module> on <event>, or time to live <duration>
(one line for every key-value bucket key and stream the work creates or starts writing; "none created" if none)
Trying the switch-over on one node first (b):
- <which node goes first, and what is read there before the rest>
- or: the operator chose no first trial on one node, on <date>; what that risks: <one sentence>
Integration level (c):
- <the one check that runs it: the group's pull requests, one check each, none before the review>
- what the check does not cover: <the parts of the group that meet only live>
```
A key or stream with no one who deletes it and no time to live is a leak and is settled before work starts.
9. **Move the issues to `fixing`**, `by` naming the feature's agent for that repository.
5. **Move the issues to `fixing`**, `by` naming the feature's agent for that repository.
## Step 1 — Develop (one fixing agent per repository, smaller model)
## Phase 1 — Develop (one fixing agent per repository, smaller model)
- **One agent per repository, kept for the whole feature.** It reads its context file once and builds the
issues one after another, committing each locally with a message naming the issue ("Keep the assignments
on the bus (issue 652)"). It is not replaced per issue: the lead continues the same agent (by its id) with
the next instruction, so its context is paid for once.
- **Unit tests only, each well under a second** (how-we-build §5, ADR 0391). **The whole unit suite runs before
every commit** (the operator, 2026-10-17), not only the tests of what changed: it takes under a second, and a
change breaks tests in packages it never touched. Each issue's first test fails before its change, on an assertion about what the
code does, and passes after. **A test that fails only because it does not compile has not failed first**: where
the test calls what the change adds, the agent takes out the change's effect (the line that does it, not the new
function), sees the test fail, and puts it back (2026-10-19: a check request's new field was proven by a test of
its helper alone, and dropping the field from the request passed every test); `gofmt`, `go vet` and the whole unit suite stay green. **The tests are written from the issue's
acceptance criteria**: one test or more per criterion at the level it names, each test's comment citing
the criterion it proves by its label (`// 1118/AC2`), and no criterion left without its test; an integration criterion is a test
that compiles and is run by the pull request's one check. The integration tests (those that start a store, a bus
or a process) are compiled with `go vet -tags integration ./...` while developing and run by that check, nowhere
else.
- **Nothing is pushed and no pull request is opened in this step, and there is no try** (ADR 0548; the operator,
2026-10-26: "every release should only run 1 check or 1 try"). When every issue is committed, the lead brings each
feature branch up to the current trunk (fetch by name, then merge) and the review reads the local commits. Never call
`mesh-controller.try`, `mesh-delivery-checker.try` or `mesh-delivery-checker.checker_try`; one is run only when the operator asks for it. A reviewer
or a session that wants the code on the forge may push a branch that has no pull request: it runs nothing. Never land
on a red check: read the result, then land in a separate step.
- **Unit tests only, of what changed, each well under a second** (how-we-build §5, ADR 0391). Each issue's
first test fails before its change and passes after; `gofmt`, `go vet`
and the repository's unit tests stay green. The integration tests (`merge-check.sh` with the store and the
bus) wait for phase 3.
- **Nothing is pushed** and no pull request is opened in this phase. Never land on a red check: run the
checks, read the result, then land in a separate step.
- **The fixing agent's brief** (the lead gives it with the context file): name your issue in every step and
message ("issue 805: landing in mesh-catalog"); reach the mesh only through the mesh MCP server, never ssh;
never push, open, merge or close a pull request; never call a try; never write to a store's database by hand, a schema change
never push, open, merge or close a pull request; never write to a store's database by hand, a schema change
is a numbered migration and the consolidated schema; Go only unless the context file says otherwise; never
move another issue; **leave the integration tests to the check** (no store or bus started by hand): compile them
with `go vet -tags integration ./...`. A single integration test may be run locally against an embedded bus when
it is the fastest way to find a cause, said in the review report and the pull request. After a failed check it
usually pays to run that repository's whole integration level locally (`go test -tags integration -count=1 ./...` in each
module), by the lead or by the fixing agent on the lead's word, before the one more push: a verdict names only the first failed
test (issue 1921), and on 2026-10-23 R4g's faults surfaced one per run until a local run listed them all in a minute (a
guideline, the operator). Report back in under 150 words: commits, tests added and that they failed first, the
local results, anything you stopped on.
move another issue. Report back in under 150 words: commits, tests added and that they failed first, the
check results, anything you stopped on.
- **An agent that finds the code differs from its context file stops and reports** (file:line); the lead
settles it, updates the context file, and continues the agent.
- **Two repositories that depend on each other** keep their order in the context files: the reader before the
@@ -231,245 +105,68 @@ issue whether it is being fixed in between, after the delivery, or waits, and on
tests pass; if another landed first, it merges again and repeats. In a shared clone, fetch the feature
branch by name before branching from it. Never resolve a conflict with a blind `git merge-file --union`.
## Step 2 — Review (one reviewer, larger model; at most 3 rounds, about 15 minutes)
## Phase 2 — Review (one reviewer, larger model; at most 3 rounds, about 15 minutes)
1. **The reviewed tree is the tree that is pushed**: every feature branch was brought up to the current trunk
before the review (step 1). The check runs on the merge with the trunk as it is then, so a
trunk that moves later is met by the forge's merge check, not by another merge here. Every verdict names the
trunk it merged onto ("pass <head> on <trunk>"), and the lead reads that trunk against the repository's main
at once: a trunk behind main means the source revision (source.main) lags, and every check in that
repository is judged on it until it moves, so the operator's Approve will merge nothing. Say it then, not at the
merge (issue 1726: on 2026-10-24 the gate dropped two merges it heard before it held its seat, and a
reviewed, approved pull request in each of the two repositories was refused at its Approve).
1. **Bring every feature branch up to the current trunk** (fetch by name, then merge), so the reviewed tree is
the tree that is pushed.
2. **One reviewer reads every repository's local commits** together, against the context files and the
decisions. **It is one agent, kept for the whole release** like the fixing agents: the lead continues it by
its id for every later round, for the extra cycle after a failed check (step 3) and for its review record, so it
reads the decisions, the issues, the context files and the commits once (issue 1042). A fresh reviewer per
round or per mark pays for all of that again. Its report is per repository: each finding as file:line, the failure it causes, and the fix.
decisions. Its report is per repository: each finding as file:line, the failure it causes, and the fix.
It says which round it is in ("round 1 of at most 3").
3. **The report goes to the same fixing agents** that wrote the code, continued by their ids, so they fix
with the context they already hold. Each answers the reviewer: what it changed, per finding, or why not.
4. **Every new integration test is read against the code it drives.** Fixing agents only compile it, and no run precedes the review (step 1), so the reviewer judges it by reading; the check that follows shows whether it passed but not whether it proves what it says. The reviewer traces each step of each new integration test through the code (fixtures against the manifest rules, what the store keeps and reads back, who holds a seat), and says which steps it could not trace. On 2026-10-17 a new test failed on its first two runs, on a fixture and then on a fault of the change; reading it against the code after the first failure found another fault, which would have failed a run too. A review that finds a fault only by reading is the cheaper run. The reviewer also checks that each new integration test enters through the same path the live trigger takes, not through a state it writes by hand: on 2026-10-17 milestone 4's test saved its delivery plan directly, so it skipped the cut from a batch that every live plan takes, and it passed while the backup step never ran (issue 1086).
5. **The reviewer reads the answers and the new commits**, and approves or reports again (step 7 says what a round
4. **The reviewer reads the answers and the new commits**, and approves or reports again (step 5 says what a round
may ask). After round 3 the lead stops and brings the operator what is left, with a recommendation.
6. **The reviewer reads every acceptance criterion against its test**: each criterion has a test at its level
that would fail without the change, and no test proves less than its criterion says. A criterion missing
for a case the issue's done-when needs is written onto the issue (step 0, item 5) and is a finding; a test
missing for a criterion is a finding.
**A rule keyed on a property is checked against everything else on the mesh that has it.** When a change
decides by a property the bus or the store gives (a stream's retention, a folder's name, an account's kind),
the reviewer lists what else already has that property, live, and asks what the rule does to it. On
2026-10-19 release 1 of feature mail-sender made a work-queue stream's dead letters drop-only by the bus's
work-queue retention; three review rounds passed it, and only its try showed that every seat's work stream has
that retention too, so dead asks across the mesh would no longer have been delivered again (issue 1178, fixed
before its pull request by a mark only a declared module work queue carries).
6a. **The reviewer reads the milestone file's three answers** (step 0, item 8) against the change: it lists every bucket
key and stream the diff creates or writes (search the diff for the bucket and stream names) and finds each in the
lifetime answer with a deleter and event, or a time to live; it reads the switch-over answer and checks it was done as
written or that the operator's choice is recorded; it reads the integration-level answer against the check the pull requests will ask. A missing answer, or a key or stream the diff creates that the answer omits, is a change asked.
7. **A finding outside the issues' done-when and their acceptance criteria is never a change asked, in any round.** More hardening, a
5. **A finding outside the issues' done-when is never a change asked, in any round.** More hardening, a
further case, a nicer form: the reviewer opens it with `issue-tracker.open` and names the number in its
report, and the round does not wait for it. What a round asks is only what makes the change wrong against
its issues, their acceptance criteria and its decisions.
8. **A timer that writes state, or a rule fixed in code that overrides what a person sets, makes the change
wrong.** The mesh is NATS-native: a part says what happened as an event and acts on events; nothing polls
and rewrites state on an interval, and a policy the operator can change is a setting the module declares,
never a constant. The reviewer reads every new or changed interval, ticker, sleep loop and periodic
run-once in the change and asks of each: what event should drive it instead, and what setting turns it off.
One with no answer is a change asked. On 2026-10-23 the forge module's loop rewrote every repository's
branch protection every ten minutes from rules in its code and undid the operator's changes at the forge
(issue 1903).
9. **At the second instance of a fault kind in a review, the developer searches for every instance and names each in the
report.** On 2026-10-25 a "busy, return at once" guard that dropped a newer request was found in three places,
one review after another. The developer writes the kind in one line, searches the repository (`git grep`) for
its shape, fixes every hit in one change and lists each file and line in the report. How it is checked: a
report to the second round naming a kind and one place only, followed by a third round on the same kind, is
this rule skipped.
its issues and its decisions.
## Step 3 — Check (no review)
## Phase 3 — Check (no review)
1. **Push the reviewed heads once, then open one pull request per repository, together** (ADR 0548; the operator,
2026-10-26: "every release should only run 1 check or 1 try"). **No pull request, draft or not, exists before the
review approves the heads**, and no fix round is pushed to an open pull request: every push to an open pull request
asks a new check, of every member for a group, and the mesh's build capacity is spent on it ("we don't have more
processing power"). The integration tests run once, in the checker's merge check on the merge with the trunk's head, at the unit
and the integration level, through the build module of the repository's language and by that language's convention
(ADR 0445, ADR 0529); the check waits on the language's check stream until a build node has a free slot
(ADR 0525 §1). **No try:** never call `mesh-controller.try`, `mesh-delivery-checker.try` or `mesh-delivery-checker.checker_try`, except when the
operator asks for one. While developing, no `go test -tags integration` run of a whole level, and no store or bus started
by hand on the machine (the operator, 2026-10-18).
**A group is one check.** Before the push, every member pins the group's own mesh-sdk head exactly (the checker refuses an
older commit of the same branch), then all members are pushed and opened together, so the group's check runs once. A head the review replaces
counts for nothing against a running check; say so, and ask no other until the new head is reviewed.
**Before a session trusts an agent's "pushed", it reads the forge branch's head** (`git ls-remote origin refs/heads/<branch>`) and compares it with the commit reviewed; an agent may commit on a detached HEAD and report a push that never happened (2026-10-26: the first check of a reviewed fix failed "not our ref"). The pull request is opened only on that read head. Pull requests in two or
1. **Push every feature branch and open one pull request per repository, together.**
The integration tests
run only in the merge check (the operator, 2026-10-16), never before. Pull requests in two or
more repositories that share the branch name are one delivery group (ADR 0239 §2).
**A temporary mode that merges by hand** (the lead merges because the normal Approve and merge path is not
running): the pull request's one check still runs the integration level, and the lead names its verdict in the
pull request's description. A try with tests `.` is asked only when the operator asks for one, and then named there with its id and verdict (research 071).
Where a session pushes through the forge tool `gitea_commit` (one commit on the forge's current base), the
reviewer first confirms that commit's tree equals the reviewed one. **A large change is pushed by a pusher agent** on the smaller
model: `gitea_commit` patches of about 30 KB each, a file copied from another path sent as a copy (`copy_from`)
and not as its bytes, and the forge's tree compared with the local one after the last part (2026-10-20: R4c-2's
two repositories went up in parts this way, and the lead's own context stayed free for the review). **Each pull request's description carries
the review record** (ADR 0421 §5), one line the lead writes from the reviewer's approval:
`Reviewed-by: <who@node> at <full sha>` (the issue 1022 note gives its form). mesh-delivery reads it at every new
head and puts it in the Approve's ask; a missing line, or one naming another commit, is said first in the ask.
It is never a gate. **Beside it, the description names the session that pushed the head**, one line
`Pushed-by: <who@node>` (ADR 0505 §5): when the head cannot merge because its check failed after the Approve (a
conflict, a failed check, a rejected group), the cure is that session's new head, and mesh-delivery tells the
operator only after two hours with no new head, naming the branch's author and this session. The session that
pushed watches its own pull request (the instruction follow-what-you-start) and pushes the new head well inside
those two hours. A description without the line names the branch alone.
**The description names its issues as `issue <n>`, and only its own** (2026-10-21: okioki #9 listed its four
issues bare, `Issues: 1608, 1646, …`, and named a milestone-2 issue as `issue 1654` and a follow-up as
`issues 1665, 1666, 1667`, so mesh-delivery carried 1654 and 1665 as the pull request's issues and the Approve's
ask named them): mesh-delivery takes a pull request's issues from the words `issue <n>` and `issues <n>` in its
description (the plural yields its first number only), so write `issue 1608, issue 1646`, and name any other
issue in words, never by `issue <n>` or `issues <n>`.
**A catalogue pull request that removes a module, or a subject a module names, is also judged by the
controller's unit tests against the catalogue's branch**, because those tests read the catalogue and no check
of the catalogue runs them: on 2026-10-20 mesh-catalog #381 removed the old delivery module, and every
controller check failed afterwards on a test of the catalogue's subjects until issue 1406 removed the name
mesh-issues still listened to. A controller's check does not find it, since a check reads the catalogue's main
(also in a delivery group that holds the catalogue's pull request); the lead runs mesh-controller's unit
suite with `MESH_CHECK_BESIDE` naming a directory that holds the catalogue's branch, as mesh-controller's
`internal/beside` says.
2. **The checker's merge check runs the integration tests once, as the gate** (its one status, `mesh/check`). No
second review: step 2 reviewed this tree.
3. **A check that fails** goes back to the same reviewer, continued by its id, with the failure, for **one more cycle** of step 2 with
the same fixing agents; then push **once** again (the one more push of ADR 0548). The check is **read, never re-asked to see if it passes**: a
check asked again without a new, reviewed head spends the build capacity for nothing. **The failure is read from the check's whole log**, never
reasoned from its summary: `mesh-delivery.show` names the check's build id, and `mesh-controller.builds` with
`log` set to that id prints everything the builder said. A check's summary picks only some failing lines;
on 2026-10-17 it showed a store test's `FAIL` without its cause, and a fix was first guessed from the code.
**A failure the check hides or loses** (a verdict naming only the first failed test, a log not kept) is a fault of the
checking tool: it is fixed there, the same turn (instruction fix-a-bug-met-twice), not asked around.
**A change to the Go build module's recipe is judged by the build module the nodes run**, not by the changed one, so a
test the change adds must pass under either version. **A change that needs the new recipe to judge it ships the recipe
first, as a small fix of its own**, and the rest waits for its delivery: on 2026-10-17 milestone 3 of design 47 moved its
integration tests behind a build tag in the same pull request as the script that ran that tag, and main's script would
have skipped every tagged test, its acceptance tests among them. Since R4g no repository holds such a script (ADR 0529):
which tests run at which level is the build tag's (ADR 0445 §2), and hq's checks are Go tests like any repository's
(ADR 0527). The prepare step looks for this: a change to what the checker's build module selects (its recipe, a build
tag convention) is cut ahead of the milestone.
4. **The milestone's acceptance is proven by its check.** A milestone that adds or changes a module
reviewer first confirms that commit's tree equals the reviewed one.
2. **The forge's merge checks run the integration tests once** (`mesh/merge-gate`, `mesh/repo-check`). No
second review: phase 2 reviewed this tree.
3. **A check that fails** goes back to the reviewer with the failure, for **one more cycle** of phase 2 with
the same fixing agents; then push once again.
4. **The step's acceptance is proven before it is marked ready.** A step that adds or changes a module
carries an integration test that starts the module whole, its process against the real bus and store, and
shows the milestone's acceptance sentence; the forge's checks run it (ADR 0391 §1, §6). Unit tests alone are not
shows the step's acceptance sentence; the forge's checks run it (ADR 0391 §1, §6). Unit tests alone are not
that proof; the mesh has no end-to-end tests yet, and the lab is not used (the operator, 2026-10-16).
**A new manifest word's test goes through the first composition that meets it, not only the verb that fills it.**
On 2026-10-19 release 1 of feature mail-sender tested a process's own bus account through `module issue`
alone; the first push after mesh-mail declared the process composed a random 40-character stand-in for the
account file before anyone issued it, and the process crash-looped until the account was issued and pushed by
hand (issue 1226).
5. **The operator's Approve is asked as soon as the checks pass** (ADR 0421). Nobody marks, labels, comments or
rechecks: mesh-delivery asks the Approve on Telegram, the channel that proves who answered today (ADR 0398 §3;
an Approve at the terminal is issue 953), once its one check (`mesh/check`) passed, for a delivery group once for all its
members, naming the review record. On the Approve it merges the approved heads, a group's in one pass
(ADR 0418). A new head ends the Approve and is asked again once its checks pass, which is why a head is pushed only after its review. A watcher follows the checks,
the Approve and the merge, and wakes the lead. Beyond that
Approve, the operator's word is waited for only when the milestone changes what the operator sees, removes
data, or needs a step only the operator's terminal can take; the lead says which before the milestone starts.
**A pull request that depends on an earlier one outside its delivery group is opened only once the earlier one is delivered**, its build
running on every machine it should reach, not merely merged. A note in the description is no safeguard: the
operator's Approve merges whatever is asked. On 2026-10-25 a second change merged before the first was delivered
and machines lost modules. How it is checked: `mesh-delivery.deliveries` shows the earlier change's source
revision `delivered` before the later pull request exists (`gitea.gitea_get_pull_request`, its creation time);
a later one older than that is this rule skipped. Members of one delivery group are the exception: they open together, with one check, one Approve and one merge in one pass (item 1).
**A reviewed follow-up to a change the operator has not yet approved is folded into that pull request** (its head
moved to include it): one Approve, one delivery. The old Approve ask closes itself on the new head and the new
head is asked once its checks pass. How it is checked: a follow-up that sits in a second pull request while the
first still waits shows in `gitea.gitea_list_pull_requests` as two open pull requests of one lead.
5. **The reviewer marks each pull request ready as soon as the acceptance is shown** with
`mesh-delivery.mark-ready` (the delivery of the head it approved, who reviewed it and why), which writes the
label `mesh/ready` and then `ready at <full sha>`, the order the forge counts (issue 913); mesh-delivery merges it when its checks pass
and the operator approved the merge into main on Telegram, the channel that proves who answered today (ADR 0364,
ADR 0398 §3; an Approve at the terminal is issue 953). Beyond that
Approve, the operator's word is waited for only when the step changes what the operator sees, removes
data, or needs a step only the operator's terminal can take; the lead says which before the step starts.
**One exception: a catalogue change that uses a new manifest word.** The catalogue's merge check reads
manifests with the controller the mesh runs, so the controller's pull request is merged and delivered first;
then `mesh-delivery.recheck` the catalogue's and merge it on the new verdict. Such work is two releases, the controller's first, and playbook 04 cuts its milestones that way.
**A new key the node-engine checks is such a word too** (issue 1051): the controller judges every machine with the
node-engine's own validator, vendored at the commit `go.mod`'s `replace` pins, so a key added to the node-engine is
refused by the gate until the controller's pin moves to the delivered node-engine (`go mod vendor`); the feature is
then three releases, the node-engine's, the controller's, the catalogue's. **Every release that must merge before
the catalogue's is on a branch name of its own** (`feat/<slug>-node-engine`, `feat/<slug>-controller`): on the
feature's name it joins the catalogue's delivery group, and a group merges only whole, so neither merges.
then `mesh-delivery.recheck` the catalogue's and merge it on the new verdict. Plan such a feature as two steps.
The exception ends when milestone 3 of design 47 is delivered (issue 977, ADR 0402, ADR 0403): the group's composed
check, judged by the group's controller, is then each member's gate, and the feature is one group.
## Step 4 — Deliver
## Phase 4 — Deliver
The merge moves the source revision; there is no delivery plan (design 47, ADR 0539). The indexer writes each
module's new fingerprint, the builder builds it once, the controller writes its compositions, credential and grant,
and the assigner writes each node's want when its seven rules hold: the module's canary first, its other nodes once
it runs healthy there. A merge into a branch other than `main` is not a delivery: no machine receives it.
**The view follows a delivery; nobody asks the controller.** The lead's watcher waits on the status view's events:
`moving` (a module's want on a node differs from its have), `settled` (the have reached the want, running and
healthy), `stuck` (held, a failed health check, or past the watchman's thirty minutes) and `delivered` (every want of
the source revision equals its fingerprint and every have its want). After each wake it reads the state again from
the view's wants page or its tools: `assigner_wants` and `assigner_want` (a want not yet at the newest fingerprint
names, in `newest` and `rule`, the rule that holds it back, and its words say why), and `node_haves` (the node
agent's haves: fingerprint, composition digest, phase and health). The delivery is done when the view says
`delivered` for its source revision; then the lead resolves the feature's issues with `fixed-by` naming its pull
requests, and removes what is left: no `feat/<slug>` branch and no `.work/<slug>` worktree survive.
**What a merge moves is read before the operator is told.** A change to mesh-sdk rebuilds every module that takes
the SDK, with their backups and their asks for a person (issue 721): on 2026-10-20 the lead said an SDK merge moved
nothing, and its delivery moved mesh-sdk with 41 dependents to all four machines. The lead reads the indexer's
candidates of the group (the view's `indexer_candidates`: each module whose fingerprint the change moves) and the
nodes that run them, and names what it moves before asking an Approve.
**A held move is the operator's answer, asked by the assigner.** A module of policy `record` whose move would
recreate a container or move data, a failed backup before an apply, and a data module whose new fingerprint failed
its health check on the canary each wait for the operator's answer to the assigner's ask; the view says them
`stuck`, and a condition names the node and the module. The lead reads the ask on the router
(`operator-channel.asks`) before telling the operator it waits, and adds it to the wait list. No session writes a
want to get past a hold.
**A new module's first assignment is its first real composition.** The composition check composes every machine as
it is assigned, so a module that no machine runs yet is composed by no check: on 2026-10-21 the build modules' first
assignment found two faults no merge check could see (issue 1537, a template the controller filled for a value the
forge serves only later; issue 1542, the node-engine refusing the module's own `.ssh`). So a new module is assigned
to one machine first, its have read after the node agent applies it (`node_haves`: phase and health) and its node's
answer read for a failed resource (`mesh-controller.node`), and taken back at once when that machine cannot compose
or apply, so the machine keeps applying everything else; the other machines follow once it runs there.
## Deliver the controller, the builder or a build module without the builder
The builder delivers its own module, the build modules and the controller from its keys, and nothing falls back to another builder
(ADR 0524 §7). When the builder or a build module cannot write references, or a controller cannot take its own fix (issue 1886: the
controller's plan could never finish, and no plan it made could deliver its own fix), the way is ADR 0526's, in this order, each
step recorded in the hand-act log with its reason:
1. **Revert, with no build.** Revert the change that broke the builder or the build module: the earlier source's fingerprint has a done key naming
references, so the revert's delivery builds nothing. Read first that the artifact store still holds the earlier artifact
(`mesh-controller.artifacts`): it keeps a recorded build while it is among its module's five newest or a definition names it, and the
controller keeps its composition while it is among the five newest too, so the assigner's want delivers it.
2. **A build by a session's call.** Call the build module's fetch and build tools on one named build node (for Go, `build_go_fetch` and then
`build_go_build` with `publish: true`); the build module pushes the artifact and answers its reference. `mesh-pack` in mesh-sdk gives a
hand build the build module's form; write the Go version of each hand build down, since another version gives another digest.
3. **Register by reference, at the controller's terminal only.** The operator names the module, its repository, its path and each artifact's
reference there; no verb reaches it. Say before you ask that this is a terminal step, and which notifications it raises; check that the
command exists on the node before you hand it over.
4. **A controller that cannot take its own fix** is put back by keys the operator writes at the terminal, with the operator's administrator
account (the operator's credential), never by a declaration sent by hand (ADR 0539 §10). To an earlier controller: the operator writes
`want.<control-node>.mesh-controller` naming a fingerprint whose composition is kept and that composition's digest; a controller
composes the one it replaced at its start, so that one is always kept. To a hand build never composed: register it as in step 3, then
`mesh-hand-compose` (in mesh-sdk, beside `mesh-pack`) composes the module for the control-node from the controller's kept inputs, signs it
with the operator's key, and with `-write` and the operator's credential writes the composition into `composed-<node>` and its digest
into `mesh-controller_composed`; the operator then writes the want naming that digest. Read the composition `mesh-hand-compose` prints
before `-write`. Each act goes into the hand-act log with its reason; say before you ask that these are terminal steps, and which
notifications they raise.
5. **Before a switch that moves the controller, the builder or a build module,** the lead keeps the running versions' artifacts outside the
artifact store, by digest on two nodes, with module, artifact, commit, Go version and the steps that put each back; the store's nightly
collection removes a blob the controller did not record.
Nothing here is a work-around for a delivery that can run normally: while the builder delivers, no hand act replaces it.
The merge starts the delivery plan: build, publish, send. A merge into a branch other than `main` is not a
delivery: no machine receives it (issue 889 while it still starts one). The lead follows it to done
(`mesh-controller.plans` with `wait` and `until: done`), then resolves the feature's issues with `fixed-by`
naming its pull requests, and removes what is left: no `feat/<slug>` branch and no `.work/<slug>` worktree
survive.
## Models
| Who | Model | Why |
|---|---|---|
| The lead (step 0 decisions, the context files, following and reporting) | larger | judgement |
| The lead (phase 0 decisions, the context files, following and reporting) | larger | judgement |
| Read-only questions about the code for the context files | smaller | reading, answered with file:line |
| Fixing agents (step 1, and the fixing in steps 2 and 3) | smaller | building to a written context |
| The reviewer (step 2, the extra cycle in step 3 and the review record; one agent, continued by its id) | larger | judgement, once per round |
| Fixing agents (phase 1, and the fixing in phases 2 and 3) | smaller | building to a written context |
| The reviewer (phase 2, and the extra cycle in phase 3) | larger | judgement, once per round |
| Decision records | larger | judgement |
Every agent the mesh runs draws on one weekly allowance shared by every session, and the larger model
@@ -485,112 +182,58 @@ on, with when it started and its bound.
|---|---|---|
| a fixing agent's issue | 30 minutes | ask it where it stands; split or take over |
| the review | 15 minutes a round | ask the reviewer; stop at round 3 |
| a merge check | 15 minutes | read `mesh-delivery.show` (its state and verdict), then the check's log; the check stream's backlog and the age of its oldest ask are in the delivery view (ADR 0525 §9) |
| the Approve's ask | 5 minutes after the check (`mesh/check`) passes | read `mesh-delivery.show`: an ask not raised is a fault, said to the operator |
| a merge after the Approve | 5 minutes | read `mesh-delivery.show` for the delivery |
| a delivery | its class's budget (3 minutes for a leaf module, 10 for a core module), at most 30 minutes | read the view's wants page, or `assigner_wants` against `node_haves`: a want held back names its rule, a `stuck` module its words |
| a merge check | 15 minutes | read the build queue (`mesh-controller.queue`) and the check's log |
| the ready mark | 15 minutes after the acceptance is shown | the reviewer marks it, or the lead does |
| a merge after ready | 5 minutes after the checks pass | read `mesh-delivery.show` for the delivery |
| a delivery plan | its class's budget (`mesh-delivery.times`), at most 30 minutes | read `mesh-controller.plans`, then `mesh-delivery.stalled` |
**Every wait on the list has a watcher** (the operator, 2026-10-17). A session runs only when a message reaches
it, so a check, a merge, a delivery, a record's review or the operator's Approve on a channel that lands
while the lead's turn has ended wakes nobody: on 2026-10-17 an approved group sat half merged for 14 minutes
unseen (issue 1032). For each wait the lead starts a watcher, an agent on the smaller model that changes nothing,
never the smallest (the operator, 2026-10-17).
It loops `mesh_wait` on the event families of what it watches (`mesh-delivery-gate.transition` and
`mesh-delivery-gate.group` for a merge, `mesh-delivery-view.moving`, `.settled`, `.stuck` and `.delivered` for its
delivery to the nodes, for example; the families a watcher may wait on are those the mesh granted
the tool runner, and a refusal names them) with a timeout under 60 seconds, since the call itself ends near
a minute. After every answer or timeout it reads the state from the verb that owns it (`mesh-delivery.show`,
the view's `assigner_wants` and `node_haves`, `knowledge-base.get`), because an event only wakes it (ADR 0367). It finishes, and so wakes
the lead, when the state changes or the wait passes its bound. The lead never sleeps or polls by hand to wait.
**A watcher watches one wait and ends with it.** It is never re-pointed at the next pull request or delivery: on
2026-10-19 one watcher sent on to three pull requests in turn missed that the third's checks had passed at 04:58,
and the lead told the operator for 15 minutes that they were still running. The next wait gets a fresh watcher,
and before a report the watcher reads the owning verb once more and says only what it says now.
**An ask is said to exist only after it is read on the router.** Before the lead tells the operator that an
Approve, a Retry or a Release waits for them, it reads `operator-channel.asks` and finds that ask open by its key;
on 2026-10-20 an ask was named to the operator that the router did not hold. A merge the gate passed but whose
ask is not on the router is the fault of the Approve's wait in the table above.
**Every ten minutes (the operator, 2026-10-17), every two minutes while a delivery moves (the view says modules `moving`), and at once when something lands, stops or
**Every five minutes, every two minutes while a delivery plan runs, and at once when something lands, stops or
needs the operator,** the lead reports, in one of the two layouts below (the operator, 2026-10-16: an overview
of the issues, often). Times are local and absolute, from the clock (`date`), never guessed; every number has its
unit and its bound or baseline; an issue is named by its number and a few words, never by its number alone.
**The full layout** (in the session) is the whole design as a tree (the operator, 2026-10-17: always the full
overview): every milestone of the design with its state, and under the running milestone its repositories and
their issues, each issue under the one it waits for, marked ✓ landed or delivered, ▶ fixing, ⏸ waiting, ✗ stopped:
**The full layout** (in the session), the issues as a tree: grouped by repository, each issue under the one it
waits for, marked ✓ landed, ▶ fixing, ⏸ waiting, ✗ stopped:
```
Feature <slug> · milestone <n> of <m> · step <name>, <minutes used> of <bound> min
Feature <slug> · step <n> of <m> · phase <name>, <minutes used> of <bound> min
Issues: <landed> of <total> landed · <fixing> fixing · <waiting> waiting
Design <nn> · <its title>
├── ✓ milestone 1 <its outcome, a few words> delivered 12:58
├── ▶ milestone 2 <its outcome, a few words>
│ ├── mesh-controller (agent: fixing, 2 of 3 landed)
│ │ ├── ✓ 652 assignments on the bus landed 14:02
│ │ ├── ▶ 736 placement words leave the layers fixing since 13:51, 23 of 30 min
│ │ │ └── ⏸ 751 config keys named (mesh-catalog) waits for 736
│ │ └── ✓ 654 a file names its template landed 13:20
│ └── mesh-catalog (agent: waiting)
│ └── ⏸ 751 (above, under 736)
└── ⏸ milestone 3 <its outcome, a few words> <its issues>
mesh-controller (agent: fixing, 2 of 3 landed)
├── ✓ 652 assignments on the bus landed 14:02
├── ▶ 736 placement words leave the layers fixing since 13:51, 23 of 30 min
│ └── ⏸ 751 config keys named (mesh-catalog) waits for 736
└── ✓ 654 a file names its template landed 13:20
mesh-catalog (agent: waiting)
└── ⏸ 751 (above, under 736)
Opened along the way: <each issue opened during the feature, its number and a few words, and when it is fixed: in between, after the delivery, or waits on what> — or "none"
Opened along the way: <each issue opened during the feature, its number and a few words, and where it goes> — or "none"
Past its bound: <the wait, how long, what is being done> — or "none"
Needs you: <one line per question, with my recommendation> — or "nothing"
Allowance: week <n> % (<+d> since the last report), session <n> %
Next: <the next actions up to the delivery, in order>
Next: <the next action, one line>
```
**The tree is read, not assembled.** `knowledge-base.progress` with `of: "design <nn>"` answers the tree's
milestones, issues, pull requests and held groups in one call, each with when it last moved: `detail: minimal` for the
five-minute report, `detailed` about every thirty minutes and whenever something stops (issue 1947). What it cannot
read for certain from the design's milestone table it says in `warnings`; the lead fixes the table, it does not
correct the tree by hand. How it is checked: a report's tree that disagrees with the verb's answer of the same minute
is a tree assembled by hand.
**The short layout** (on a channel such as Telegram, or when nothing changed but time):
```
<slug> milestone <n>/<m> · <step> <used>/<bound> min · <landed>/<total> landed · past bound: <none | what> · needs you: <nothing | what> · week <n> %
<slug> step <n>/<m> · <phase> <used>/<bound> min · <landed>/<total> landed · past bound: <none | what> · needs you: <nothing | what> · week <n> %
```
A report says what changed since the last one first. In the session every report is the full layout, the whole
tree of the milestone's issues included, also when only time passed: the operator reads the state from it without
tree of the step's issues included, also when only time passed: the operator reads the state from it without
asking. The short layout is for a channel.
## The context of the lead's own session
At the end of each step, and whenever the session's context grows large or spread over unrelated work, the
At the end of each phase, and whenever the session's context grows large or spread over unrelated work, the
lead prepares a `/clear` as the mesh's instruction "prepare before clear" says: a save point with the
feature's step, its branches and heads, the context files' paths, the agents' ids, the wait list and the
feature's phase, its branches and heads, the context files' paths, the agents' ids, the wait list and the
next action; then the prompt the operator pastes. The context files make the feature resumable after a clear.
## The design's state is written back
Other sessions know what is built only from the design's frontmatter and the issue tracker; what the lead
knows and does not write back, nobody else knows. A design merged in one delivery with three of its tasks
left, its frontmatter still saying `designed`, read to another session as not built, and a second design
was started beside it (issue 1009, 2026-10-17). So the lead writes the design's state back at each of these
moments, the same hour, an hq change going through [playbook 08](08-small-fixes.md):
- **The first milestone starts:** the design's `status: in-progress` ([playbook 04](04-build-handoff.md) step 7),
if it is not already.
- **A milestone is delivered** (the view says `delivered` for its source revision): its issues are resolved (step 4), with `fixed-by` naming its pull
requests. The design's milestone entry is not changed: it names the milestone's issues, and whether they are
delivered, when and by what is read from the issue tracker when it is asked, never copied into the design
(issue 1038; AGENTS.md: cross-cutting views are generated when they are read).
- **The building pauses with work left** (another design takes its turn, a hand-over, the session's end): the
design stays `in-progress`, and each issue left gets a note with `issue-tracker.note` saying what it waits
for and what comes after it ("built after design N is delivered, before design M"). The first issue of
a design that waits its turn gets the same note.
- **The last milestone is delivered:** [playbook 02](02-graduation.md), "When something ships": the as-is is
written and the design says `implemented`.
## Issues that stop
An issue stays in `fixing` only while an agent works on it. A milestone that stops, for a wait past its bound, a
An issue stays in `fixing` only while an agent works on it. A step that stops, for a wait past its bound, a
hand-over, or the session's end, moves each of its issues not delivered back to `open` with
`issue-tracker.move` and a note saying how far it got (its branch, its last commit, what is left). A list of
issues in `fixing` that nobody works on reads to the operator as stuck work.
@@ -599,56 +242,17 @@ issues in `fixing` that nobody works on reads to the operator as stuck work.
- A feature's issues show their commits on the feature branch (`git log --grep 'issue <n>'`), and no pull
request into a feature branch exists. A pull request per issue, or an agent waiting on a merge check in
step 1, is the failure this playbook prevents.
phase 1, is the failure this playbook prevents.
- A context file exists per touched repository under `.work/<slug>/` while the feature runs.
- No issue of a running milestone is sized over an hour in its prepare note; an issue whose fixing agent passes
twice its bound was not split.
- Every issue of a running milestone has a note from the prepare step naming its files and tests (file:line), how
it is checked and delivered, and for a group its merge order (`issue-tracker.get`); an issue moved to `fixing`
without one is a prepare step skipped.
- Every issue of a running milestone has a note headed `Acceptance criteria` before it moves to `fixing`
(`issue-tracker.get`), each criterion naming its test level, and every criterion's label is cited by a test in the
pushed tree (`git grep -n '<issue>/AC<n>'`); an issue without them is a prepare step skipped, and a
criterion no test names is a review that missed it (until issue 1118 makes the tracker refuse it).
- Every fixing agent's report gives the whole unit suite's result and time; a report naming only some packages'
tests is the whole-suite rule not kept.
- The lead's save point names each watcher with its model; a watcher on the smallest model is the failure of
"Following and reporting".
- A report or a save point that names a "phase" of a milestone is the old wording: a milestone has steps.
- Every repository's own clone says `main` (`git -C <clone> branch --show-current`); another branch there is
work done outside a worktree (issue 1010).
- After a feature's delivery, `git branch -r | grep feat/<slug>` and `git worktree list` return nothing for it.
- A design whose milestone issues are partly resolved says `in-progress`, and one whose issues are all
resolved and delivered says `implemented`: read its Milestones section against `issue-tracker.get` for each
issue. A design that says `designed` with a resolved milestone issue is a state not written back.
- A design's Milestones section names each milestone's issues and nothing of their state: a "delivered", a date
or a pull request in an entry is a copy of the tracker's state (issue 1038), read with
`grep -n 'delivered' <design>` over its Milestones section.
- Every open issue of a design that says `in-progress` and has no agent on it shows, in `issue-tracker.get`, a
note saying what it waits for; one without such a note is a pause not written back.
- The milestone issues in `fixing` (`issue-tracker.list`) belong to one design at most: milestone issues
of two designs in `fixing` at once is the failure of "one design is built at a time".
- More than one open pull request in a repository that share no feature name, or a milestone whose issues
- More than one open pull request in a repository that share no feature name, or a feature step whose issues
stay in `fixing` for more than a working day, is the smell: stop and bring it to the operator.
- A review report that asks a change outside the issues' done-when and acceptance criteria, or a fourth round, is the failure of the
- A review report that asks a change outside the issues' done-when, or a fourth round, is the failure of the
review rules; `issue-tracker.list` with `flag: abandoned` lists issues left in `fixing` with nobody on them.
- A pull request green for more than 5 minutes with no Approve asked is a fault of mesh-delivery
(`mesh-delivery.show`), said to the operator.
- Every review record of a release names one reviewer (`mesh-delivery.show`, the review record), and the lead's
save point names one reviewer id per release; a second reviewer within one release is a reviewer not kept
(issue 1042). A pull request whose ask says it has no review record at its head is a record not written.
- A running milestone's file holds the three answers of step 0 item 8 (`cat .work/<slug>/milestone.md`), and the
review report says it read them (step 2, item 6a): a bucket key or stream in the diff (`git diff main --
| grep -i 'bucket\|stream'`) that the lifetime answer omits is the failure of that item (research 071).
- A pull request merged by hand in a temporary mode names, in its description on the forge (`gitea.gitea_get_pull_request`), the
verdict of its one check; a try with tests `.` is named only where the operator asked for one.
- **One release, one check** (ADR 0548): `gitea.gitea_get_pull_request` shows each pull request created after its
review record's time, and `mesh-delivery.show` shows one check per head and, for a group, one head per member; a pull
request that existed before its review approved it, a second head with no failed check before it, or a check of a
head asked more than once, is this rule skipped. A call of `mesh-controller.try`, `mesh-delivery-checker.try` or `checker_try` that the operator
did not ask for shows in the checker's asks and is the same breach.
- A milestone that adds or changes a module whose pull request has no integration test that starts the module
whole is the failure of the check step's fourth item.
- A pull request reviewed and green but not marked ready for more than 15 minutes is a lead that did not
mark it.
- A step that adds or changes a module marked ready with no integration test that starts the module whole is
the failure of phase 3's fourth step.
- A bed run from `.work/<slug>/mesh-lab` that fails naming a `.work/<slug>/<repo>/…` path it cannot find is a
missing sibling worktree, not a mesh fault.
+33 -86
View File
@@ -16,88 +16,47 @@ hour is a feature.
**Who runs it.** Anyone who writes code, engineers and agents alike.
**A fix found during a milestone rides in that milestone's group** (the operator, 2026-10-21, after a day with ten
mesh-sdk merges, each rebuilding about forty dependent modules, and eight mesh-controller merges, each sending a
delivery to every node). Playbook [07](07-feature-branches.md) makes a milestone one piece of functionality
released once as one delivery group (ADR 0482). A small fix in a repository the running milestone touches, mesh-sdk
and mesh-controller above all, is built as below but on the milestone's branch, so its pull request joins the group
and merges with it. The exception is a fault that is broken for the operator now (something they see failing at
this moment): it goes alone as a small fix, and the lead's report says why.
## The aim
A small fix spends its time on the fix, and runs the long part once: **one review, one push, one check run,
one delivery** (ADR 0548; the operator, 2026-10-26: "every release should only run 1 check or 1 try"; "we don't have more
processing power"). Everything before the push is local and fast, and no try is run.
one delivery**. Everything before the push is local and fast.
## Steps
1. **Read the issue and the code it names** (the session's own model, or one read-only agent on the smaller
model answering exact questions with file:line). If a design question is open, it is settled before any
code is written: on the issue, through the knowledge base (`knowledge-base.ask` first: what is decided is read before anything is designed), or by the operator. A fix that turns out to
code is written: on the issue, through the knowledge base, or by the operator. A fix that turns out to
need a second repository, or more than about an hour, stops here and becomes a feature; one that needs a
decision stays a small fix. **The session's own model (the larger) then writes the issue's acceptance
criteria** as playbook [07](07-feature-branches.md) step 0 item 5 says: each one sentence, given, when, then,
naming the level of the test that proves it, as an `issue-tracker.note` headed `Acceptance criteria`. A
playbook or design edit has none: its check is `go test ./...` at hq's root, which runs the four checks (ADR 0527).
decision stays a small fix.
2. **Move the issue to `fixing`** with `by` as who@node.
3. **Build on a local branch** `fix/<issue>-<slug>`, in a worktree of its own branched from `main`, never in
the repository's own clone, which stays on `main` (playbook [07](07-feature-branches.md), step 0, item 2). Write the
test that fails today first, on an assertion about what the code does: a test that fails only because it
does not compile has not failed first (playbook [07](07-feature-branches.md) step 1); make the change; keep `gofmt`, `go vet` and the repository's whole unit suite green
before every commit (each test well under a second; never a merge check or a database locally: how-we-build §5, ADR 0391).
Check that it compiles with `go vet`, never `go build` in a module's folder: with one main package it writes the
program beside `go.mod`, and `git add -A` takes it (issue 1577: two 5-10 MB programs committed on 2026-10-21,
caught only by the review); read `git show --stat` before pushing.
The tests are written from the acceptance criteria, at least one per criterion at its level, each citing the
criterion it proves by its label (`<issue>/AC<n>`, playbook 07 step 0 item 5). Commit, naming the issue. For a playbook or design edit, run the repository's own checks (in hq,
`go test ./...` at the root). The integration tests are compiled (`go vet -tags integration ./...`) and not run:
nothing is pushed, no pull request is opened and no try (`mesh-controller.try`, `mesh-delivery-checker.try`, `mesh-delivery-checker.checker_try`) is called, unless the operator asks for
one. A single integration test may be run locally against an embedded bus when it is the fastest way to find a cause.
4. **One review of the local commit** (a reviewer that did not write it, larger
model; at most 3 rounds, as playbook 07; from round 2 only what is wrong; it reads each acceptance criterion
against its test, as playbook 07 step 2 item 6; a finding outside the issue's done-when and its acceptance
criteria is opened as an issue of its own, never asked as a change; it judges the integration tests by reading them against the code, as playbook 07 step 2 item 4 says). The reviewer's findings go back to the same agent that wrote the fix, continued by
3. **Build on a local branch** `fix/<issue>-<slug>`, in a worktree of its own branched from `main`. Write the
test that fails today first; make the change; keep `gofmt`, `go vet` and the unit tests of what changed green
(each well under a second; never a merge check or a database locally: how-we-build §5, ADR 0391).
Commit, naming the issue. For a playbook or design edit, run the repository's own checks (in hq,
`sh merge-check.sh`).
4. **One review of the local commit, before anything is pushed** (a reviewer that did not write it, larger
model; at most 3 rounds, as playbook 07; from round 2 only what is wrong; a finding outside the issue's
done-when is opened as an issue of its own, never asked as a change). The reviewer's findings go back to the same agent that wrote the fix, continued by
its id, so the fix keeps its context. A playbook or design edit of a few lines may skip the reviewer: the
operator's Approve of its merge is its review, and the review record says so.
5. **Push the reviewed head once and open the pull request, only after the review approves.** The trunk is what runs and nothing holds merged work back
(ADR 0401), so a fix waits for no other change's delivery. No pull request, draft or not, exists before the review
approves the head, and no fix round is pushed to an open pull request: every push to an open pull request asks a new
check. The checker's merge check runs the unit and the integration level once, as the gate (playbook [07](07-feature-branches.md) step 3); the pushed head is read from the forge first, as playbook 07 step 3 item 1 says.
A failed check is read from its whole log (never re-asked to see if it passes), then the repository's whole integration level
is run locally (`go test -tags integration -count=1 ./...` in each module) so every failure shows at once, where a verdict
names only the first (issue 1921); the failures go back to the same agent, fixed in one change, the reviewer reads only the change
it makes, and the head is pushed once more.
**A temporary mode that merges by hand** (the lead merges because the normal Approve and merge path is not
running): the one check still runs, and the lead names its verdict in the pull request's description; a try with tests `.` is asked only when the operator asks for one (research 071; playbook
[07](07-feature-branches.md) step 3).
6. **The pull request's description carries the review record** (`Reviewed-by: <who@node> at <full sha>`,
playbook [07](07-feature-branches.md) step 3) and the session that pushed it (`Pushed-by: <who@node>`, ADR 0505 §5), and mesh-delivery asks the operator's Approve as soon as its
checks pass and merges the approved head (ADR 0421); nobody marks, labels, comments or rechecks. A fix to a module that changes how it runs carries an
integration test starting the module whole against the real bus and store, run by the checker's one merge check
operator's word on the pull request is its review, and the lead marks it ready on that word.
5. **Push once and open the pull request.** The trunk is what runs and nothing holds merged work back
(ADR 0401), so a fix waits for no other change's delivery. The forge's merge
check runs the integration tests, the only
place they run (the operator, 2026-10-16). A failed check
goes back to the same agent with the failure, and the reviewer reads only the change it makes.
6. **The reviewer marks it ready at once** (for an edit that skipped the reviewer, the lead, on the
operator's word) with `mesh-delivery.mark-ready` (the delivery of the head it approved, who reviewed it and
why), which writes the label `mesh/ready` and then `ready at <full sha>`, the order the forge counts (issue 913);
mesh-delivery merges it when its checks pass. A fix to a module that changes how it runs carries an
integration test starting the module whole against the real bus and store, run by the forge's checks
(ADR 0391 §1, §6); unit tests alone are not that proof, and the lab is not used. The operator's word is waited for only when the fix changes what the operator sees, removes data,
or needs the operator's terminal.
**A fix that depends on another pull request outside its delivery group opens only once that one is delivered**, not merely merged (members of one group open together, as playbook 07 step 3 item 1 says) (a note
in the description does not hold it back: the Approve merges whatever is asked; 2026-10-25, machines lost modules
when a second change merged first). **A reviewed follow-up to a fix still waiting for the Approve is folded into
that pull request** (head moved): one Approve, one delivery. Both are checked as playbook
[07](07-feature-branches.md) step 3 says.
**At the second instance of a fault kind found in a review, the developer searches for every instance** and names
each in the report (playbook [07](07-feature-branches.md) step 2, item 9).
7. **Follow its delivery to done**: a watcher waits on the status view's events (`mesh-delivery-view.moving`,
`.settled`, `.stuck` and `.delivered`) and reads the view's `assigner_wants` and `node_haves` after each wake, as
playbook [07](07-feature-branches.md) step 4 says; the delivery is done when the view says `delivered` for its
source revision. Then prove the fix on the mesh (the symptom gone, measured), and resolve the issue with
`located-in` and `fixed-by` the same hour.
7. **Follow its delivery plan to done** (`mesh-controller.plans` with `wait` and `until: done`), prove the fix on
the mesh (the symptom gone, measured), and resolve the issue with `located-in` and `fixed-by` the same hour.
A fix that stops before its delivery moves its issue back to `open` with a note saying how far it got
(branch, last commit, what is left), as playbook [07](07-feature-branches.md) says.
**An issue opened along the way is fixed too** (the operator, 2026-10-21). A fix's review, check or delivery that
opens an issue (a remark outside the done-when, a fault met live) leaves it to the same session to fix: right after
this fix's delivery, as a small fix of its own, or folded into the next one in the same repository. It waits only
when another running milestone owns it, or it needs a decision or the operator first; the report then says on what.
**A second fault found by the delivery.** When a fix's delivery shows a second fault that keeps the issue's
done-when from holding, and that fault is in the same files, it is taken into the same issue as a second pull
request with a review round of its own. A fault in other code is opened as an issue of its own, and the first
@@ -118,10 +77,10 @@ code.
|---|---|---|
| building the fix | 1 hour | it is a feature: stop and plan it under playbook 07 |
| the review | 15 minutes | ask the reviewer where it stands |
| the merge check | 30 minutes (it waits behind the asks before it for a build node's free slot, ADR 0525) | read `mesh-delivery.show`, the check's log and the check stream's backlog in the delivery view |
| the Approve's ask | 5 minutes after the check (`mesh/check`) passes | read `mesh-delivery.show`: an ask not raised is a fault, said to the operator |
| the merge after the Approve | 5 minutes | read `mesh-delivery.show` for the delivery |
| the delivery | its class's budget (3 minutes for a leaf module, 10 for a core module), at most 30 minutes | read the view's wants page, or `assigner_wants` against `node_haves`: a want held back names its rule, a `stuck` module its words |
| the ready mark | 15 minutes after the review approved | the reviewer marks it, or the lead does |
| the merge check | 15 minutes | read `mesh-controller.queue` and the check's log |
| the merge after ready | 5 minutes after the checks pass | read `mesh-delivery.show` for the delivery |
| the delivery plan | its class's budget (`mesh-delivery.times`), at most 30 minutes | read `mesh-controller.plans`, then `mesh-delivery.stalled` |
A wait past its bound is said to the operator at once, with what is being done about it.
@@ -133,22 +92,10 @@ reading itself and uses one subagent only for the review.
## How it is checked
- A small fix has one pull request with one or few commits naming its issue, opened after its review: a pull
request with many heads is a fix reviewed after its pull request was opened.
- A pull request merged by hand in a temporary mode names the verdict of its one check in its description
on the forge (`gitea.gitea_get_pull_request`); one without is the step skipped.
- One fix, one check (ADR 0548): a pull request that existed before its review approved the head, or a head whose check was
asked more than once with no new reviewed head, is steps 4 and 5 skipped; so is a `mesh-controller.try` or `mesh-delivery-checker.try` the operator did not ask for.
- Its issue moves to `resolved` within the hour the view says its source revision `delivered`.
- A pull request with no review record at its head (the Approve's ask says so first), a fix to a module's
running pushed with no integration test of the module whole, or a review report
asking a change outside the issue's done-when and acceptance criteria, is the failure of steps 4 to 6.
- A fixed code issue shows a note headed `Acceptance criteria` (`issue-tracker.get`) older than its first
commit, and each criterion's label is cited by a test in the pushed tree
(`git grep -n '<issue>/AC<n>'`); one without is step 1 skipped.
request with many heads is a fix reviewed after its push.
- Its issue moves to `resolved` within the hour its delivery plan ends.
- A pull request reviewed and green but unmarked
for more than 15 minutes, a fix to a module's running marked ready with no integration test of the module whole, or a review report
asking a change outside the issue's done-when, is the failure of steps 4 to 6.
- An issue in `fixing` with nobody on it shows in `issue-tracker.list` with `flag: abandoned`; a stopped fix
moves it back to `open` with its note.
- A fix found during a milestone is named in the lead's report with the group it rides in, or with the reason it
went alone. `mesh-delivery.deliveries` shows the milestone's members in one group, merged as one source revision
per milestone; a merge of mesh-sdk or mesh-controller outside the group, with no reported reason, is this rule skipped.
- The repository's own clone says `main` (`git -C <clone> branch --show-current`); another branch there is a
fix built outside a worktree (issue 1010).
-66
View File
@@ -1,66 +0,0 @@
# Playbook 10 — Visual design
**Trigger.** A page or a screen the operator will use is about to be designed or rebuilt, or the operator judges
one unusable. It runs before the decisions about its look are written and before any of its code: a page built
first and judged after it ships is rebuilt (the operator, 2026-10-22, on design 59's mail page, judged unusable
the day it was delivered).
**Who runs it.** The lead of the design, on the larger model. Mockups may be drawn by an agent on the smaller
model from the lead's brief. The operator picks; nobody else does.
## What it produces
- **The reference**: one mockup the operator picked, kept with the design as `NN-mockup/` beside its to-be
document. It is a direction, not a pixel specification: the build matches what it shows the operator doing,
in the order and the weight it shows, not its exact pixels.
- **The decisions it needs**: what the pick changes about what the mesh keeps, shows or does, written as decision
records (playbook [02](02-graduation.md)) before the design's milestones (playbook [04](04-build-handoff.md)).
- **An acceptance test in the operator's words**: the page is done when the operator uses it, on the devices they
named, for the work the mockup shows.
## Steps
1. **Gather what the page must carry.** A page whose direction needs research first starts at playbook
[01](01-research.md); this playbook then draws it. Read the as-is of what it replaces and the real shape of its data from the
mesh (how many items, how long a line, what is missing in practice), the operator's complaint in their own
words, and a short survey of how others solve it (a handful of products, one line each on what changes for
the person). Write it into the work folder; nothing goes on the mesh.
2. **Draw three to five mockups with fixed data.** Each is one static HTML file that opens from disk: invented
data shaped like the real (names, amounts, lengths, a long subject, an empty sender), no backend, a mock script
only where an interaction is the point. Each mockup is a different direction, not a variation of one; each
shows a phone's width and a desktop's. One index page links them all.
3. **Show them and ask one question.** Put the index on the machine the operator is at, and ask which direction to
keep, one question at a time with the lead's recommendation. A round's answer may be a pick, a combination of
two, or a change; the next round draws only that. Three rounds without a pick mean something the operator wants
is missing: ask what, before drawing again.
4. **Make the pick the reference.** Copy it into the design's `NN-mockup/`, with every name, address, employer,
mail address, domain, provider and amount of the operator's own replaced by invented ones: hq is public. Say in the design that
the reference is a direction, and link it.
5. **Write what is new as decisions,** each with the operator's Approve where it changes what they see, then the
design and its milestones. The design's acceptance tests end with the operator using the page live.
6. **Hold the build to it.** The lead gives playbook [07](07-feature-branches.md)'s reviewer the reference with
the built page, at a phone's and a desktop's width, as part of what the review reads against the issue. The
operator using the page is the milestone's live proof, made before the next milestone starts (playbook
[04](04-build-handoff.md)).
## Bounds
| Waiting on | Bound | Past the bound |
|---|---|---|
| a round of mockups | 1 hour | say to the operator what is taking long |
| the operator's pick | the next exchange | ask once more, one question, with the recommendation |
| rounds without a pick | 3 | ask what is missing before drawing again |
## Rules
- No code reaches the mesh during a visual design: mockups live in the work folder until one is picked.
- Mockups use invented data, never a copy of the operator's mail, files or accounts.
- The operator's pick stands over the lead's taste; the lead's recommendation is said once, with its reason.
## How it is checked
- A to-be design of a page the operator uses links its reference mockup in `NN-mockup/`, and that mockup holds
none of the operator's own names, addresses, domains, providers or amounts (read by the reviewer of the design's pull request).
- The milestone's review read the built page beside the reference at a phone's and a desktop's width (its
report says so).
- The design's last acceptance test is the operator using the page, proven before the next milestone starts.
-521
View File
@@ -1,521 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Novox Playbooks</title>
<style>
:root{
--bg:#f7f6f2; --card:#ffffff; --ink:#1d1f23; --mute:#5d6470; --line:#d9d6cc; --soft:#efede6;
--idea:#2f5fa7; --sym:#b4462b; --dec:#7a4bb0; --build:#3c8a5a; --mesh:#0f6f7a; --op:#c2410c; --side:#8a6d1f;
--ok:#15803d; --okbg:#e6f4ea; --warn:#a16207; --warnbg:#fdf3d7; --bad:#b91c1c; --badbg:#fde8e8;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#15171b; --card:#1e2127; --ink:#e8e6e1; --mute:#9aa1ad; --line:#353a43; --soft:#262a31;
--idea:#7aa7ef; --sym:#f08a6c; --dec:#b993ef; --build:#6fcf97; --mesh:#4fc3cf; --op:#fb923c; --side:#d9b44a;
--ok:#4ade80; --okbg:#173222; --warn:#facc15; --warnbg:#3a3016; --bad:#f87171; --badbg:#3a1d1d;
}
}
:root[data-theme="dark"]{
--bg:#15171b; --card:#1e2127; --ink:#e8e6e1; --mute:#9aa1ad; --line:#353a43; --soft:#262a31;
--idea:#7aa7ef; --sym:#f08a6c; --dec:#b993ef; --build:#6fcf97; --mesh:#4fc3cf; --op:#fb923c; --side:#d9b44a;
--ok:#4ade80; --okbg:#173222; --warn:#facc15; --warnbg:#3a3016; --bad:#f87171; --badbg:#3a1d1d;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:1120px;margin:0 auto;padding:28px 16px 72px}
h1{font-size:28px;margin:0 0 4px}
h2{font-size:20px;margin:44px 0 6px}
h3{font-size:16px;margin:0 0 6px}
p{margin:6px 0 10px}
.sub{color:var(--mute);margin:0 0 18px}
.lead{max-width:760px}
nav{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 6px}
nav a,.chip{display:inline-block;padding:4px 11px;border:1px solid var(--line);border-radius:999px;color:var(--ink);text-decoration:none;font-size:13px;background:var(--card);cursor:pointer}
nav a:hover,.chip:hover{border-color:var(--mute)}
.chip.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.box{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin:12px 0}
.scroll{overflow-x:auto}
svg{display:block;max-width:100%;height:auto;font-family:inherit}
svg text{fill:var(--ink);font-size:13px}
svg .mute{fill:var(--mute);font-size:11.5px}
svg .t{font-weight:600}
svg .edge{stroke:var(--mute);stroke-width:1.6;fill:none}
svg .dash{stroke-dasharray:5 4}
.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:13px;color:var(--mute);margin-top:8px}
.legend i{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:6px;vertical-align:-1px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.pb{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;border-top:4px solid var(--c,var(--line))}
.pb .num{font:600 12px/1 ui-monospace,monospace;color:var(--c);letter-spacing:.04em}
.pb h3{margin:4px 0 4px;font-size:17px}
.pb .trig{color:var(--mute);font-size:13.5px;margin:0 0 8px}
.pb details{border-top:1px solid var(--line);padding-top:6px;margin-top:6px}
.pb summary{cursor:pointer;font-size:13.5px;font-weight:600;color:var(--ink);list-style:none}
.pb summary::before{content:"▸ ";color:var(--mute)}
.pb details[open] summary::before{content:"▾ "}
.pb ol,.pb ul{margin:6px 0 4px;padding-left:20px;font-size:13.5px}
.pb li{margin:2px 0}
.kv{display:grid;grid-template-columns:96px 1fr;gap:4px 10px;font-size:13px;margin-top:6px}
.kv b{color:var(--mute);font-weight:500}
.tag{display:inline-block;font-size:11.5px;padding:1px 7px;border-radius:999px;background:var(--soft);color:var(--mute);margin:0 4px 4px 0}
.new{background:var(--okbg);color:var(--ok);font-weight:600}
table{border-collapse:collapse;width:100%;font-size:14px}
th,td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--mute);font-weight:600;font-size:12.5px;text-transform:uppercase;letter-spacing:.03em}
.note{border-left:3px solid var(--ok);background:var(--okbg);padding:9px 12px;border-radius:6px;font-size:14px}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media (max-width:760px){.two{grid-template-columns:1fr}}
footer{color:var(--mute);font-size:13px;margin-top:48px}
code{font:12.5px ui-monospace,monospace;background:var(--soft);padding:1px 5px;border-radius:4px}
.theme{float:right}
</style>
</head>
<body>
<main>
<button class="chip theme" id="theme" type="button">Theme</button>
<h1>Novox Playbooks</h1>
<p class="sub">How work is done on the mesh, from an idea or a symptom to a delivered change. Drawn from hq's <code>00-META/process/</code> at main <code>f269e83</code>, 2026-10-19, amended 2026-10-26 for ADR 0548 (one release, one check: no try).</p>
<nav>
<a href="#cycle">The cycle</a>
<a href="#feature">A feature, step by step</a>
<a href="#tests">When tests run</a>
<a href="#review">The review loop</a>
<a href="#issues">An issue's life</a>
<a href="#where">Where things live</a>
<a href="#all">All playbooks</a>
</nav>
<!-- ============ THE CYCLE ============ -->
<h2 id="cycle">The development cycle</h2>
<p class="lead">Work enters as an <b>idea</b> or a <b>symptom</b>. An idea becomes a decision, a design and then milestones; a symptom becomes an issue. Both end as code delivered to the machines, and the as-is design is updated. <b>No design without a decision; no development without a design or an issue that sends it.</b></p>
<div class="box scroll">
<svg viewBox="0 0 1080 470" role="img" aria-label="The development cycle across playbooks 01 to 09">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0L10,5L0,10z" fill="var(--mute)"/></marker>
</defs>
<!-- entry points -->
<g>
<rect x="16" y="40" width="120" height="50" rx="25" fill="none" stroke="var(--idea)" stroke-width="2"/>
<text x="76" y="70" text-anchor="middle" class="t">Idea</text>
<rect x="16" y="300" width="120" height="50" rx="25" fill="none" stroke="var(--sym)" stroke-width="2"/>
<text x="76" y="330" text-anchor="middle" class="t">Symptom</text>
</g>
<!-- idea path -->
<g>
<rect x="176" y="30" width="150" height="70" rx="10" fill="var(--card)" stroke="var(--idea)" stroke-width="2"/>
<text x="251" y="58" text-anchor="middle" class="t">01 Research</text>
<text x="251" y="78" text-anchor="middle" class="mute">questions, findings,</text>
<text x="251" y="92" text-anchor="middle" class="mute">direction confirmed</text>
<rect x="366" y="30" width="160" height="70" rx="10" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="446" y="58" text-anchor="middle" class="t">02 Graduation</text>
<text x="446" y="78" text-anchor="middle" class="mute">decisions in the</text>
<text x="446" y="92" text-anchor="middle" class="mute">knowledge base · to-be</text>
<rect x="566" y="30" width="160" height="70" rx="10" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="646" y="58" text-anchor="middle" class="t">04 Breakdown</text>
<text x="646" y="78" text-anchor="middle" class="mute">milestones by release,</text>
<text x="646" y="92" text-anchor="middle" class="mute">issues per repository</text>
<rect x="766" y="30" width="150" height="70" rx="10" fill="var(--card)" stroke="var(--build)" stroke-width="2.5"/>
<text x="841" y="58" text-anchor="middle" class="t">07 Feature</text>
<text x="841" y="78" text-anchor="middle" class="mute">one milestone =</text>
<text x="841" y="92" text-anchor="middle" class="mute">one release</text>
</g>
<!-- symptom path -->
<g>
<rect x="176" y="290" width="150" height="70" rx="10" fill="var(--card)" stroke="var(--sym)" stroke-width="2"/>
<text x="251" y="318" text-anchor="middle" class="t">03 Issues</text>
<text x="251" y="338" text-anchor="middle" class="mute">opened in the tracker,</text>
<text x="251" y="352" text-anchor="middle" class="mute">diagnosed, located</text>
<rect x="566" y="290" width="160" height="70" rx="10" fill="var(--card)" stroke="var(--build)" stroke-width="2.5"/>
<text x="646" y="318" text-anchor="middle" class="t">08 Small fix</text>
<text x="646" y="338" text-anchor="middle" class="mute">one issue, one repository,</text>
<text x="646" y="352" text-anchor="middle" class="mute">about an hour</text>
</g>
<!-- delivered + as-is -->
<g>
<rect x="950" y="160" width="116" height="74" rx="10" fill="var(--card)" stroke="var(--mesh)" stroke-width="2.5"/>
<text x="1008" y="188" text-anchor="middle" class="t">Delivered</text>
<text x="1008" y="206" text-anchor="middle" class="mute">Approve, merge,</text>
<text x="1008" y="220" text-anchor="middle" class="mute">wants and haves</text>
<rect x="766" y="160" width="150" height="74" rx="10" fill="var(--card)" stroke="var(--dec)" stroke-width="2" stroke-dasharray="5 4"/>
<text x="841" y="188" text-anchor="middle" class="t">As-is updated</text>
<text x="841" y="206" text-anchor="middle" class="mute">02 "when it ships":</text>
<text x="841" y="220" text-anchor="middle" class="mute">to-be → implemented</text>
</g>
<!-- side playbooks -->
<g>
<rect x="176" y="400" width="190" height="56" rx="10" fill="var(--soft)" stroke="var(--side)"/>
<text x="271" y="424" text-anchor="middle" class="t">05 Constitution sync</text>
<text x="271" y="442" text-anchor="middle" class="mute">how-we-build → derived page</text>
<rect x="396" y="400" width="190" height="56" rx="10" fill="var(--soft)" stroke="var(--side)"/>
<text x="491" y="424" text-anchor="middle" class="t">06 Writing a module</text>
<text x="491" y="442" text-anchor="middle" class="mute">what runs today → a module</text>
<rect x="876" y="400" width="190" height="56" rx="10" fill="var(--soft)" stroke="var(--side)"/>
<text x="971" y="424" text-anchor="middle" class="t">09 Cleanup</text>
<text x="971" y="442" text-anchor="middle" class="mute">branches, worktrees, on the word</text>
</g>
<!-- edges -->
<path class="edge" d="M136,65 H174" marker-end="url(#ar)"/>
<path class="edge" d="M326,65 H364" marker-end="url(#ar)"/>
<path class="edge" d="M526,65 H564" marker-end="url(#ar)"/>
<path class="edge" d="M726,65 H764" marker-end="url(#ar)"/>
<path class="edge" d="M916,65 C990,65 1008,100 1008,158" marker-end="url(#ar)"/>
<path class="edge" d="M136,325 H174" marker-end="url(#ar)"/>
<path class="edge" d="M326,325 H564" marker-end="url(#ar)"/>
<text x="445" y="317" text-anchor="middle" class="mute">a fix of about an hour</text>
<path class="edge" d="M726,325 C990,325 1008,290 1008,236" marker-end="url(#ar)"/>
<path class="edge" d="M948,197 H918" marker-end="url(#ar)"/>
<path class="edge dash" d="M251,290 C251,200 420,170 440,102" marker-end="url(#ar)"/>
<text x="300" y="196" class="mute">a design gap: decision first</text>
<path class="edge dash" d="M326,300 C560,250 720,170 800,102" marker-end="url(#ar)"/>
<text x="612" y="236" class="mute">larger than an hour: a feature</text>
<path class="edge dash" d="M1008,236 C1008,330 990,360 971,398" marker-end="url(#ar)"/>
</svg>
<div class="legend">
<span><i style="background:var(--idea)"></i>from an idea</span>
<span><i style="background:var(--sym)"></i>from a symptom</span>
<span><i style="background:var(--dec)"></i>decisions and designs</span>
<span><i style="background:var(--build)"></i>code changes</span>
<span><i style="background:var(--mesh)"></i>the mesh delivers</span>
<span><i style="background:var(--side)"></i>alongside</span>
</div>
</div>
<!-- ============ FEATURE SWIMLANE ============ -->
<h2 id="feature">A feature, step by step (playbook 07)</h2>
<p class="lead">One milestone is one release: one branch name in every repository it touches, one worktree per repository, one fixing agent per repository kept for the whole milestone, one reviewer, and one pull request per repository, opened once. Nothing is pushed or tried before the review (ADR 0548): the branches are pushed <b>once, after the review approves</b>, and the one check runs the integration tests.</p>
<div class="box scroll">
<svg viewBox="0 0 1080 430" role="img" aria-label="Playbook 07 in lanes: lead, fixing agents, reviewer, the mesh, the operator">
<defs>
<marker id="ar2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0L10,5L0,10z" fill="var(--mute)"/></marker>
</defs>
<!-- step columns -->
<g class="mute">
<text x="190" y="20" text-anchor="middle" class="t" style="fill:var(--mute)">0 · Prepare</text>
<text x="190" y="34" text-anchor="middle" class="mute">15 min</text>
<text x="410" y="20" text-anchor="middle" class="t" style="fill:var(--mute)">1 · Develop</text>
<text x="410" y="34" text-anchor="middle" class="mute">30 min per issue · local only</text>
<text x="630" y="20" text-anchor="middle" class="t" style="fill:var(--mute)">2 · Review</text>
<text x="630" y="34" text-anchor="middle" class="mute">≤ 3 rounds · 15 min</text>
<text x="830" y="20" text-anchor="middle" class="t" style="fill:var(--mute)">3 · Check</text>
<text x="830" y="34" text-anchor="middle" class="mute">push once · 1 check</text>
<text x="1000" y="20" text-anchor="middle" class="t" style="fill:var(--mute)">4 · Deliver</text>
<text x="1000" y="34" text-anchor="middle" class="mute">until delivered</text>
</g>
<!-- lanes -->
<g>
<rect x="0" y="46" width="1080" height="72" fill="var(--soft)" opacity=".55"/>
<rect x="0" y="190" width="1080" height="72" fill="var(--soft)" opacity=".55"/>
<rect x="0" y="334" width="1080" height="72" fill="var(--soft)" opacity=".55"/>
<text x="10" y="86" class="t">Lead</text><text x="10" y="102" class="mute">larger model</text>
<text x="10" y="158" class="t">Fixing agents</text><text x="10" y="174" class="mute">smaller model</text>
<text x="10" y="230" class="t">Reviewer</text><text x="10" y="246" class="mute">larger model</text>
<text x="10" y="302" class="t">The mesh</text><text x="10" y="318" class="mute">the one check, wants</text>
<text x="10" y="374" class="t">Operator</text><text x="10" y="390" class="mute">Telegram</text>
</g>
<!-- lead -->
<rect x="110" y="56" width="160" height="52" rx="8" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="190" y="78" text-anchor="middle" class="t">Survey · criteria</text>
<text x="190" y="96" text-anchor="middle" class="mute">context file per repo</text>
<rect x="340" y="56" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="410" y="78" text-anchor="middle" class="t">Up to trunk</text>
<text x="410" y="96" text-anchor="middle" class="mute">each branch, local</text>
<rect x="760" y="56" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="830" y="78" text-anchor="middle" class="t">Push reviewed heads</text>
<text x="830" y="96" text-anchor="middle" class="mute">once, PRs together</text>
<rect x="930" y="56" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/>
<text x="1000" y="78" text-anchor="middle" class="t">Follow to done</text>
<text x="1000" y="96" text-anchor="middle" class="mute">resolve the issues</text>
<!-- fixing -->
<rect x="300" y="128" width="220" height="52" rx="8" fill="var(--card)" stroke="var(--build)" stroke-width="2"/>
<text x="410" y="150" text-anchor="middle" class="t">Build each issue, commit</text>
<text x="410" y="168" text-anchor="middle" class="mute">unit suite before every commit</text>
<rect x="560" y="128" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--build)" stroke-width="2"/>
<text x="630" y="150" text-anchor="middle" class="t">Fix findings</text>
<text x="630" y="168" text-anchor="middle" class="mute">the same agents</text>
<!-- reviewer -->
<rect x="560" y="200" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--idea)" stroke-width="2"/>
<text x="630" y="222" text-anchor="middle" class="t">Read all repos</text>
<text x="630" y="240" text-anchor="middle" class="mute">reads tests · every criterion</text>
<!-- mesh -->
<rect x="340" y="272" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--mesh)" stroke-width="2"/>
<text x="410" y="294" text-anchor="middle" class="t">no try</text>
<text x="410" y="312" text-anchor="middle" class="mute">nothing pushed yet</text>
<rect x="760" y="272" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--mesh)" stroke-width="2"/>
<text x="830" y="294" text-anchor="middle" class="t">Forge checks</text>
<text x="830" y="312" text-anchor="middle" class="mute">one check · mesh/check</text>
<rect x="930" y="272" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--mesh)" stroke-width="2"/>
<text x="1000" y="294" text-anchor="middle" class="t">Merge · wants</text>
<text x="1000" y="312" text-anchor="middle" class="mute">backups first, canary first</text>
<!-- operator -->
<rect x="860" y="344" width="140" height="52" rx="8" fill="var(--card)" stroke="var(--op)" stroke-width="2.5"/>
<text x="930" y="366" text-anchor="middle" class="t">Approve</text>
<text x="930" y="384" text-anchor="middle" class="mute">once, for the group</text>
<!-- edges -->
<path class="edge" d="M190,108 C190,140 260,154 298,154" marker-end="url(#ar2)"/>
<path class="edge" d="M410,128 V110" marker-end="url(#ar2)"/>
<path class="edge" d="M630,200 V182" marker-end="url(#ar2)"/>
<path class="edge dash" d="M610,182 V198" marker-end="url(#ar2)"/>
<text x="514" y="196" class="mute">round n+1</text>
<path class="edge" d="M700,226 C740,226 740,82 758,82" marker-end="url(#ar2)"/>
<text x="712" y="140" class="mute">approved</text>
<path class="edge" d="M830,108 V270" marker-end="url(#ar2)"/>
<path class="edge" d="M900,298 C915,298 920,330 925,342" marker-end="url(#ar2)"/>
<path class="edge" d="M1000,344 V326" marker-end="url(#ar2)"/>
<path class="edge" d="M1000,272 V110" marker-end="url(#ar2)"/>
</svg>
</div>
<div class="two">
<div class="box">
<h3>Step 0 · what the lead writes before any agent starts</h3>
<ul style="margin:6px 0;padding-left:20px;font-size:14px">
<li>Every open question settled: on the issue, in the knowledge base, or by the operator.</li>
<li>Into each issue: every file and test it touches (file:line), how it is checked and delivered, a group's merge order.</li>
<li><span class="tag new">new</span>Every other reader and writer of each value the change touches, each with a criterion.</li>
<li>Acceptance criteria: given / when / then, each naming its test level.</li>
<li>Every part the change reaches is an issue of the milestone; their order is the assigner's rules', never a cut.</li>
<li>Fixtures copied from what the other side really writes, never from memory.</li>
<li>An issue sized over an hour is split before work starts.</li>
</ul>
</div>
<div class="box">
<h3>Step 1 · the fixing agent's brief</h3>
<ul style="margin:6px 0;padding-left:20px;font-size:14px">
<li>One commit per issue, naming it; tests cite their criterion (<code>// 1217/AC3</code>).</li>
<li><span class="tag new">new</span>A test fails first on what the code does. A compile error is no failure: take out the change's effect and see an assertion fail.</li>
<li>The whole unit suite before every commit; each test well under a second.</li>
<li>Integration tests are compiled only: never run on the machine.</li>
<li>Never push, open or merge; never move an issue; never ssh.</li>
<li>Code that differs from the context file: stop and report, file:line.</li>
</ul>
</div>
</div>
<!-- ============ TESTS ============ -->
<h2 id="tests">When tests run</h2>
<p class="lead">Two levels. Unit tests run all the time on the machine; integration tests (a bus, a store or a process started) are compiled there and never run: the checker's one merge check, asked by the one push after the review, runs them as the gate. There is no try (ADR 0548).</p>
<div class="box scroll">
<svg viewBox="0 0 1080 170" role="img" aria-label="Test levels over a feature's timeline">
<defs><marker id="ar3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0L10,5L0,10z" fill="var(--mute)"/></marker></defs>
<path class="edge" d="M120,140 H1060" marker-end="url(#ar3)"/>
<text x="120" y="162" class="mute">develop</text><text x="470" y="162" class="mute">end of step 1</text><text x="640" y="162" class="mute">review</text><text x="800" y="162" class="mute">step 3</text><text x="960" y="162" class="mute">merge</text>
<text x="10" y="44" class="t">Unit</text><text x="10" y="60" class="mute">on the machine</text>
<g fill="var(--build)">
<rect x="120" y="36" width="700" height="16" rx="8" opacity=".25"/>
<circle cx="160" cy="44" r="6"/><circle cx="220" cy="44" r="6"/><circle cx="290" cy="44" r="6"/><circle cx="350" cy="44" r="6"/><circle cx="420" cy="44" r="6"/><circle cx="600" cy="44" r="6"/><circle cx="680" cy="44" r="6"/>
</g>
<text x="830" y="42" class="mute">whole suite before every commit</text><text x="830" y="56" class="mute">each test under a second</text>
<text x="10" y="104" class="t">Integration</text><text x="10" y="120" class="mute">never on the machine</text>
<g>
<rect x="120" y="96" width="340" height="16" rx="8" fill="var(--mute)" opacity=".18"/>
<text x="290" y="108" text-anchor="middle" class="mute">compiled only: go vet -tags integration</text>
<rect x="770" y="90" width="120" height="28" rx="6" fill="none" stroke="var(--mesh)" stroke-width="2" stroke-dasharray="5 4"/><text x="830" y="109" text-anchor="middle" class="mute">push once</text>
<rect x="920" y="90" width="120" height="28" rx="6" fill="var(--mesh)"/><text x="980" y="109" text-anchor="middle" style="fill:var(--bg)" class="t">merge check</text>
</g>
</svg>
</div>
<!-- ============ REVIEW LOOP ============ -->
<h2 id="review">The review loop</h2>
<div class="two">
<div class="box scroll">
<svg viewBox="0 0 520 300" role="img" aria-label="The review loop: at most three rounds">
<defs><marker id="ar4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0L10,5L0,10z" fill="var(--mute)"/></marker></defs>
<rect x="20" y="20" width="150" height="54" rx="8" fill="var(--card)" stroke="var(--idea)" stroke-width="2"/>
<text x="95" y="44" text-anchor="middle" class="t">Round n of ≤ 3</text><text x="95" y="62" text-anchor="middle" class="mute">code + criteria</text>
<polygon points="300,20 380,47 300,74 220,47" fill="var(--card)" stroke="var(--mute)"/>
<text x="300" y="51" text-anchor="middle" class="t">Wrong?</text>
<rect x="400" y="20" width="110" height="54" rx="8" fill="var(--okbg)" stroke="var(--ok)" stroke-width="2"/>
<text x="455" y="44" text-anchor="middle" class="t">Approve</text><text x="455" y="62" text-anchor="middle" class="mute">Reviewed-by line</text>
<rect x="210" y="130" width="180" height="54" rx="8" fill="var(--card)" stroke="var(--build)" stroke-width="2"/>
<text x="300" y="154" text-anchor="middle" class="t">Same fixing agents fix</text><text x="300" y="172" text-anchor="middle" class="mute">answer per finding</text>
<rect x="20" y="130" width="160" height="54" rx="8" fill="var(--soft)" stroke="var(--side)"/>
<text x="100" y="154" text-anchor="middle" class="t">issue-tracker.open</text><text x="100" y="172" text-anchor="middle" class="mute">anything beyond the issue</text>
<rect x="300" y="236" width="210" height="54" rx="8" fill="var(--warnbg)" stroke="var(--warn)" stroke-width="2"/>
<text x="405" y="260" text-anchor="middle" class="t">After round 3: the operator</text><text x="405" y="278" text-anchor="middle" class="mute">what is left, with a recommendation</text>
<path class="edge" d="M170,47 H218" marker-end="url(#ar4)"/>
<path class="edge" d="M380,47 H398" marker-end="url(#ar4)"/><text x="384" y="38" class="mute">no</text>
<path class="edge" d="M300,74 V128" marker-end="url(#ar4)"/><text x="306" y="104" class="mute">yes</text>
<path class="edge" d="M210,157 C140,157 95,120 95,76" marker-end="url(#ar4)"/><text x="120" y="112" class="mute">n+1</text>
<path class="edge dash" d="M250,66 C200,100 170,120 150,128" marker-end="url(#ar4)"/><text x="178" y="96" class="mute">hardening,</text><text x="178" y="108" class="mute">a nicer form</text>
<path class="edge dash" d="M360,184 C380,210 400,220 405,234" marker-end="url(#ar4)"/>
</svg>
</div>
<div class="box">
<h3>What a round may ask</h3>
<p style="font-size:14px">Only what makes the change <b>wrong</b> against its issues, their acceptance criteria and its decisions. Anything more (hardening, a further case, a nicer form) becomes a new issue, and the round does not wait for it.</p>
<h3 style="margin-top:12px">What every round reads</h3>
<ul style="margin:6px 0;padding-left:20px;font-size:14px">
<li>Every criterion against its test: a test at its level that would fail without the change.</li>
<li>Every new integration test traced through the code by reading it: no run exists before the check.</li>
<li>That each integration test enters by the path the live trigger takes.</li>
</ul>
<div class="note" style="margin-top:10px">Why reviews took more than one round (R3b, 2026-10-19): a test that "failed" only by not compiling, a reader of the changed record the survey missed, and bus tests judged by reasoning before they ran. ADR 0548 and playbook 07 step 2 item 4 change the last: bus tests are judged by reading them against the code, and the one check runs them after the review.</div>
</div>
</div>
<!-- ============ ISSUE LIFE ============ -->
<h2 id="issues">An issue's life (playbook 03)</h2>
<p class="lead">The issue tracker (seat <code>issue-tracker</code>) holds every issue's number, status, owner, fix and links. Its <code>move</code> refuses a wrong move at the write.</p>
<div class="box scroll">
<svg viewBox="0 0 1080 230" role="img" aria-label="Issue statuses and the moves between them">
<defs><marker id="ar5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0L10,5L0,10z" fill="var(--mute)"/></marker></defs>
<g>
<rect x="20" y="70" width="120" height="50" rx="25" fill="var(--card)" stroke="var(--sym)" stroke-width="2"/><text x="80" y="100" text-anchor="middle" class="t">open</text>
<rect x="220" y="70" width="140" height="50" rx="25" fill="var(--card)" stroke="var(--warn)" stroke-width="2"/><text x="290" y="100" text-anchor="middle" class="t">diagnosing</text>
<rect x="440" y="70" width="120" height="50" rx="25" fill="var(--card)" stroke="var(--dec)" stroke-width="2"/><text x="500" y="100" text-anchor="middle" class="t">located</text>
<rect x="640" y="70" width="120" height="50" rx="25" fill="var(--card)" stroke="var(--build)" stroke-width="2"/><text x="700" y="100" text-anchor="middle" class="t">fixing</text>
<rect x="860" y="70" width="140" height="50" rx="25" fill="var(--okbg)" stroke="var(--ok)" stroke-width="2.5"/><text x="930" y="100" text-anchor="middle" class="t">resolved</text>
<rect x="440" y="170" width="120" height="44" rx="22" fill="var(--soft)" stroke="var(--mute)"/><text x="500" y="197" text-anchor="middle" class="t">wontfix</text>
</g>
<path class="edge" d="M140,95 H218" marker-end="url(#ar5)"/><text x="146" y="86" class="mute">by</text>
<path class="edge" d="M360,95 H438" marker-end="url(#ar5)"/><text x="366" y="86" class="mute">located-in</text>
<path class="edge" d="M560,95 H638" marker-end="url(#ar5)"/><text x="590" y="86" class="mute">by</text>
<path class="edge" d="M760,95 H858" marker-end="url(#ar5)"/><text x="770" y="138" class="mute">fixed-by, delivered,</text><text x="770" y="152" class="mute">proven · core: replay</text>
<path class="edge dash" d="M140,105 C300,150 520,150 640,108" marker-end="url(#ar5)"/><text x="300" y="150" class="mute">straight to fixing, with by</text>
<path class="edge dash" d="M500,120 V168" marker-end="url(#ar5)"/><text x="508" y="150" class="mute">reason</text>
<path class="edge dash" d="M700,70 C700,30 600,30 590,30 C400,30 120,30 90,68" marker-end="url(#ar5)"/><text x="320" y="24" class="mute">stopping: back to open, with a note of branch, last commit and what is left</text>
</svg>
<div class="legend"><span>A worker idle for a day may be taken over without a reason; otherwise <code>take-over</code> with a reason. An issue about a condition is linked to it, so the alert names the issue.</span></div>
</div>
<!-- ============ WHERE THINGS LIVE ============ -->
<h2 id="where">Where things live</h2>
<div class="box scroll">
<table>
<tr><th>What</th><th>Lives in</th><th>Written with</th><th>Never</th></tr>
<tr><td>Decisions (ADRs) and research efforts</td><td>the knowledge base (seat <code>knowledge-base</code>)</td><td><code>reserve</code>, then <code>propose</code> the record whole</td><td>a file in hq</td></tr>
<tr><td>Issues: status, owner, fix, links</td><td>the issue tracker</td><td><code>open</code>, <code>move</code>, <code>note</code>, <code>link</code></td><td>a status key in a report</td></tr>
<tr><td>An issue's narrative (design or governance only)</td><td>hq <code>04-ISSUES/NNN-…/</code></td><td>a pull request</td><td>its status</td></tr>
<tr><td>Designs, as-is and to-be</td><td>hq <code>03-DESIGN/</code>, status in frontmatter</td><td>a pull request</td><td>the two layers mixed</td></tr>
<tr><td>Playbooks, glossary, how-we-build</td><td>hq <code>00-META/</code></td><td>a pull request</td><td>a copy in a session's memory</td></tr>
<tr><td>The rules every session keeps</td><td>instruction sections (module <code>claude-code</code>)</td><td><code>claude_code_instructions_propose</code>, the operator's Approve</td><td>a hand-edited file</td></tr>
</table>
</div>
<!-- ============ ALL PLAYBOOKS ============ -->
<h2 id="all">All playbooks</h2>
<p class="sub">Open a card for its steps, what it produces and how it is checked.</p>
<div id="filters" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px"></div>
<div class="grid" id="cards"></div>
<footer>Drawn from hq main f269e83 on 2026-10-19 and kept in hq beside the playbooks. The playbooks are the authority; this page is a picture of them, and a change to a playbook's steps updates it in the same pull request (not yet checked: issue 1222).</footer>
</main>
<script>
const P = [
{n:"01",name:"Research",kind:"idea",c:"--idea",
trig:"An idea worth investigating before it is committed to design. A symptom is not research.",
who:"The session that heard the idea leads.",
steps:["Read what is known: ask, get, the tracker's list.","Ask 3 to 5 setup questions, one at a time, each with a recommendation.","Check every option against the mission and how-we-build first.","Reserve a number (kind research), never pick one by hand.","Propose the effort: overview, questions, method, with links.","Research; findings in 04-findings, one version per settled set.","Try at the cheapest level that answers the question.","Write 03-domains: modules, seats, glossary words added or retired.","Write 05-direction: decisions, rejected options, acceptance tests.","Ask the operator to confirm the direction, one point at a time.","If the review asks changes: propose again, at most three times."],
prod:"A research effort in the knowledge base, status active, every version kept. Nothing in hq.",
next:"02",bounds:"Setup 30 min · proposing 15 min · each experiment 30 min · direction 30 min · report every 10 min",
prev:["Research that measured nothing","A recommendation the operator must argue back","Efforts copied into hq"],
check:"propose refuses rule breaks; the knowledge base reviews; the records test on every PR."},
{n:"02",name:"Graduation & design change",kind:"idea",c:"--dec",
trig:"A research effort concludes, or an existing design must change.",
who:"The lead, on the larger model; the decision is recorded before the design moves.",
steps:["Check the conclusion against mission, context and effect.","Reserve a number per decision.","Propose one record per choice that can be reversed on its own.","Pre-check, then propose siblings together.","On changes: revise whole; after three rounds fix findings yourself.","A finding that would change what was decided: ask the operator.","needs-operator: wait for the operator's Approve.","Amend the design, or create a to-be design with status designed.","Add the decisions, updated:, and the effort's glossary words.","Hand the design to the breakdown (04).","Close the effort: graduated with became links, or abandoned.","When it ships: write the as-is, set the to-be implemented."],
prod:"Decision records; a to-be design with frontmatter; glossary entries; later the as-is.",
next:"04",bounds:"One sitting of about an hour",
prev:["A decision rewritten silently","A deferral nothing tracks","A design amended without a decision"],
check:"propose refuses; the records and index tests; set-wide rules raise conditions."},
{n:"03",name:"Issues",kind:"symptom",c:"--sym",
trig:"Something is wrong: an unenforced rule, a stated behaviour that does not happen, a silent failure.",
who:"Anyone opens; a worker claims it by moving it.",
steps:["Open it in the tracker: title, report; take the number it gives.","Tracker unreachable: take no number, say so.","A design or governance issue: a folder in hq with the report.","About a condition: link the condition's key.","Move to diagnosing with by, before investigating.","Note the trail; a folder issue also gets a dated diagnosis.","Move to located with located-in, a registered owner.","Move to fixing with by, before fixing.","Name fixed-by at the merge; stay fixing until proven live.","Move to resolved; a core issue gives replay or replay-none.","A design gap: run 02 and give amended-design.","Not going to be fixed: wontfix with a reason."],
prod:"A tracker record with notes and links; for a design issue, its narrative folder in hq.",
next:"02 · 07 · 08",bounds:"A worker idle for a day is stale",
prev:["Numbers that collide","Two agents starting the same work","Status held in two places"],
check:"The tracker's move refuses at the write; the cycle test checks the folders."},
{n:"04",name:"Work breakdown & handoff",kind:"idea",c:"--dec",
trig:"A to-be design is settled and its building is about to start.",
who:"The lead who will run the feature, on the larger model.",
steps:["Confirm status designed; each decision traces to a record.","An open question remains: back to research.","Set code: to the owning repositories.","Read the as-is; write it first if it does not say what is replaced.","Write the Milestones: outcome, acceptance tests, repositories.","Cut milestones by functionality, never by an order between parts; fold any not usable alone.","Ask the operator to confirm the milestones.","Open one issue per repository part, linked depends-on.","Write the issue numbers into the design; flip to in-progress.","Hand each milestone to 07, one at a time, one design at a time."],
prod:"Milestones in the design; issues per repository part; the design in-progress.",
next:"07",bounds:"One sitting · one design built at a time · a milestone is one release",
prev:["Building without a breakdown","Building against an undocumented as-is","Deferrals with no owning issue"],
check:"Read by the lead and the reviewer; a check is tracked by issue 845."},
{n:"05",name:"Constitution sync",kind:"side",c:"--side",
trig:"how-we-build.md changed a rule the mesh enforces at runtime.",
who:"Whoever made the change.",
steps:["Edit how-we-build; the reasoning stays there.","Record the change as a decision (02).","Publish the derived page under its slug.","Keep the section numbering stable.","Read the page back; verify the change is there.","Note the sync in the record's consequences."],
prod:"A decision record; the derived constitution page injected into design sessions.",
next:"02",bounds:"no time bound",
prev:["Drift between the reasoned and the enforced copy","A publish that said success and did nothing","Edits to the derived page that vanish"],
check:"The read-back in step 5."},
{n:"06",name:"Writing a module",kind:"side",c:"--side",
trig:"Something that runs today must run on the mesh.",
who:"Whoever ports or writes it.",
steps:["Read what runs: compose file, environment, where data sits.","Name what it provides and requires.","Declare capabilities for machine facts.","Write resources in the order they must happen.","Every secret in a file, never in env.","Pin every image by digest.","New: generate the credential; adopted: accept it.","Prove it against the real software: auth, refusal, restart."],
prod:"A module declaration, optionally a provisioner, and its proof.",
next:"07",bounds:"no time bound",
prev:["A service running with no password","A new password locking out adopted data","A test that cannot fail"],
check:"Example tests refuse the env-file mistake; review."},
{n:"07",name:"Features",kind:"build",c:"--build",
trig:"Work that changes code across issues or repositories, too large for a small fix.",
who:"A lead (larger model), one fixing agent per repository (smaller), one reviewer (larger).",
steps:["Prepare: name the branch, make the worktrees, settle every question.","Write one context file per repository.","Write the findings and acceptance criteria into each issue.","Survey every other reader and writer of each changed value. (new)","Answer three questions in the milestone file: who deletes each new key and stream, how the switch-over is tried on one node first, what the one check does not cover. The review checks them. (new)","List every part the change reaches; copy fixtures from the real writer.","Develop: one agent per repository, one commit per issue.","A test fails first on behaviour, not on compiling. (new)","Bring each branch up to the trunk; nothing is pushed, no try runs. (new)","Review all of it, reading the integration tests against the code; at most 3 rounds.","A group pins the group's own mesh-sdk head in every member. (new)","Push the reviewed heads once and read each forge head against the reviewed commit; no draft pull request, no fix round on an open one. (new)","Open one pull request per repository, together, with Reviewed-by and Pushed-by; the one check runs. A failed check is read, fixed, reviewed, pushed once more. (new)","The operator's Approve on Telegram; the group merges in one pass.","Follow the delivery in the status view (moving, settled, stuck, delivered) to done; resolve the issues; clean up. (new)","When the builder cannot deliver its own fix: revert, a build by a session's call, a registration at the terminal, copies kept outside the store; a broken controller is put back by a want the operator writes, a hand build by mesh-hand-compose and a want. (new)"],
prod:"One pull request per repository, one source revision delivered, resolved issues, no branch or worktree left.",
next:"02 (when it ships)",bounds:"Prepare 15 min · 30 min per issue · review 15 min, ≤ 3 rounds · one check, pushed once",
prev:["Pull requests as scratchpads","Context bought again and again","Review rounds spent on what a run would show","A node agent's bus reads, proven only by review"],
check:"The checker's one status, mesh/check; the review record; the tracker's move."},
{n:"08",name:"Small fixes",kind:"build",c:"--build",
trig:"One issue in one repository, about an hour of work.",
who:"The session, with one reviewer on the larger model.",
steps:["Read the issue and the code; settle any open question.","Write the acceptance criteria.","Move the issue to fixing.","Build on fix/<issue>-<slug> in its own worktree.","A test that fails today first, on behaviour. (new)","Nothing is pushed and no try runs before the review. (new)","One review of the local commit.","Push the reviewed head once; only then open the pull request. (new)","The operator's Approve; the merge; the delivery, followed in the status view. (new)","Prove the fix live and resolve the same hour.","During a milestone: ride in its delivery group, unless broken for the operator now. (new)"],
prod:"One pull request, one delivery, a resolved issue.",
next:"—",bounds:"About an hour · one review, one push, one check",
prev:["A fix reviewed after its pull request opened","A long part run twice","A fix waiting on someone else's delivery","Ten SDK merges and eight controller merges in a day, one per fix"],
check:"The checker's status; the review record; the tracker's move; one delivery group and one source revision per milestone."},
{n:"09",name:"Cleanup",kind:"side",c:"--side",
trig:"The operator asks, or merged branches, stale pull requests and old worktrees pile up.",
who:"A session on the operator's word; the operator decides every deletion.",
steps:["Take stock, read-only.","Write one report: item, class, evidence.","Classify: safe, likely safe, kept, unknown.","Report stale pull requests; never close them.","Ask: delete the safe items as listed?","Delete only the approved list; never force.","Report what was cleaned, kept and unknown."],
prod:"A stock report, the approved deletions, issues for missing tools.",
next:"03",bounds:"A worktree is safe only if untouched for 2 h · a PR is stale after 3 days",
prev:["Deleting unfinished work","Deletion by name or age alone","Missing tools left as session notes"],
check:"The report names the evidence for each deletion."},
{n:"10",name:"Visual design",kind:"idea",c:"--idea",
trig:"A page the operator will use is about to be designed or rebuilt, or the operator judges one unusable.",
who:"The design's lead; the operator picks.",
steps:["Gather what the page must carry: the as-is, the real data's shape, the operator's words, how others do it.","Draw three to five mockups with fixed, invented data, phone and desktop. (new)","Show them; ask one question per round. (new)","Make the pick the reference, with the operator's own details replaced. (new)","Write what is new as decisions, then the design and its milestones.","Hold the build to the reference; done when the operator uses it."],
prod:"The reference mockup beside the design, its decisions, an acceptance test of live use.",
next:"02, then 04",bounds:"A round 1 h · 3 rounds without a pick: ask what is missing",
prev:["A page judged unusable the day it ships","Rebuilding after delivery"],
check:"The design links its mockup; the review shows the build beside it; the operator's use is the last test."}
];
const kinds={all:"All",idea:"From an idea",symptom:"From a symptom",build:"Code changes",side:"Alongside"};
let current="all";
function esc(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;")}
function render(){
const g=document.getElementById("cards"); g.innerHTML="";
P.filter(p=>current==="all"||p.kind===current).forEach(p=>{
const d=document.createElement("article"); d.className="pb"; d.style.setProperty("--c",`var(${p.c})`);
d.innerHTML=`<div class="num">PLAYBOOK ${p.n}</div><h3>${esc(p.name)}</h3><p class="trig">${esc(p.trig)}</p>
<div>${p.bounds.split(" · ").map(b=>`<span class="tag">${esc(b)}</span>`).join("")}</div>
<details><summary>Steps (${p.steps.length})</summary><ol>${p.steps.map(s=>`<li>${esc(s).replace("(new)",'<span class="tag new">new</span>')}</li>`).join("")}</ol></details>
<details><summary>Produces, prevents, checked by</summary><div class="kv">
<b>Who</b><span>${esc(p.who)}</span><b>Produces</b><span>${esc(p.prod)}</span>
<b>Then</b><span>${esc(p.next)}</span><b>Prevents</b><span>${p.prev.map(esc).join("; ")}</span>
<b>Checked by</b><span>${esc(p.check)}</span></div></details>`;
g.appendChild(d);
});
}
const f=document.getElementById("filters");
Object.entries(kinds).forEach(([k,v])=>{const b=document.createElement("button");b.type="button";b.className="chip"+(k===current?" on":"");b.textContent=v;
b.onclick=()=>{current=k;[...f.children].forEach(x=>x.classList.remove("on"));b.classList.add("on");render()};f.appendChild(b)});
render();
const root=document.documentElement;
try{const t=localStorage.getItem("pb-theme");if(t)root.dataset.theme=t}catch(e){}
document.getElementById("theme").onclick=()=>{const dark=root.dataset.theme?root.dataset.theme==="dark":matchMedia("(prefers-color-scheme: dark)").matches;
root.dataset.theme=dark?"light":"dark";try{localStorage.setItem("pb-theme",root.dataset.theme)}catch(e){}};
</script>
</body>
</html>
-1
View File
@@ -36,7 +36,6 @@ one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the tabl
| `mesh-surfaces` | 3 | tools, web, cli |
| `mesh-sdk` | — | the stable spine modules build against — the tool-serving SDK harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile (ADR 0039). |
| `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. |
| `mesh-delivery` | — | the delivery domain: the parts that build, check and deliver a change by watching and writing keys on the bus, nine modules each whole in it, prefixed `mesh-delivery-` (ADR 0429). Made by the forge module from its config (ADR 0428). |
Tier 4's shape is open, and deliberately so: see ADR 0019 and
research 005.
+1 -1
View File
@@ -14,7 +14,7 @@ the commit the knowledge base's records name as their source (issue 426).
This folder keeps one thing: the six topics of the reading order, in the order somebody would learn the
system. Each record names its own topic, and the knowledge base reads these lines through the records module
and refuses a record whose topic is not one of them. the index test (`00-META/checks/`) checks that they are written.
and refuses a record whose topic is not one of them. `index.py` checks that they are written.
1. **What the mesh is** — `topic: the mesh`. What it is for, and the parts it is made of.
2. **Its tiers, from the bottom up** — `topic: the tiers`. The foundation, the node and the controller, and
+59 -104
View File
@@ -1,11 +1,12 @@
---
layer: as-is
status: implemented
code: [mesh-delivery, mesh-catalog modules/mesh-delivery-gate, mesh-catalog modules/gitea, mesh-catalog modules/mesh-build-go, mesh-catalog modules/messenger, mesh-catalog modules/mesh-issues, mesh-controller, mesh-host, mesh-sdk]
updated: 2026-10-26
code: [mesh-controller, mesh-catalog modules/mesh-delivery, mesh-catalog modules/gitea, mesh-catalog modules/build-agent]
updated: 2026-10-17
decisions:
- "ADR 0239"
- "ADR 0267"
- "ADR 0276"
- "ADR 0282"
- "ADR 0289"
- "ADR 0304"
@@ -16,120 +17,74 @@ decisions:
- "ADR 0382"
- "ADR 0395"
- "ADR 0398"
- "ADR 0429"
- "ADR 0524"
- "ADR 0525"
- "ADR 0527"
- "ADR 0528"
- "ADR 0529"
- "ADR 0539"
- "ADR 0546"
- "ADR 0547"
---
# Delivery — from a merge to the nodes, as it runs
What runs on 2026-10-26, after the delivery of release 6 (milestone R6 of
[to-be 47](../01-to-be/47-delivery-from-commit-to-delivered.md), ADR 0539), read from the code on each repository's
main (`mesh-delivery`, the gate in `mesh-catalog`, the node agent in `mesh-host`, `mesh-controller`) and from the live
mesh: the controller's seat verbs, `mesh-controller.modules`, the gate's `table`, and the view's `assigner_wants` and
`node_haves`. Until 2026-10-26 this document described release 5, in which the controller still made a delivery plan
for each merge, ordered it in tiers and sent whole declarations to the machines; none of that runs any more.
What runs on the mesh's main branches on 2026-10-16, read from the code and the live record by research 055
(findings 1 to 12). It is what [to-be 47](../01-to-be/47-delivery-from-commit-to-delivered.md) replaces, milestone
by milestone. Until 2026-10-16 this document described the predecessor's pipeline, which no longer runs.
## Who does what
- **The parts of `mesh-delivery`** are modules of one repository, each a process with its own bus credential that
watches keys and writes keys (ADR 0429). Running now: the forge bridge, the indexer, the checker, the teller and the
watchman on the control-node; the builder and the status view (`mesh-delivery-view`) on the workstation; and the
assigner on the home server. The gate (`mesh-delivery-gate`, holding the seat `mesh-delivery`) is a module of
`mesh-catalog` and runs on the control-node.
- **The forge bridge** turns the forge's events into stream messages and is the only part that calls the forge; the
forge module (`gitea`) still announces them through the forge's system webhook.
- **The indexer** writes each module's fingerprint at the merge preview and at a trunk merge; **the builder** claims a
build once per fingerprint and orders it as an ask on the stream of the artifact's language.
- **The build modules** (`mesh-build-go` on all four machines, `mesh-build-node` on three, `mesh-build-image` on one;
ADR 0525) hold the node seats `node-build-go`, `node-build-node` and `node-build-image` and pull asks from the bus's
work-queue streams (deliveries first, then checks, then re-checks) while their node has a free slot of build
capacity.
- **The checker** runs the merge check and writes the one status `mesh/check`, and serves a **try** on a pushed
branch. The forge requires no status on any repository (ADR 0534), and no repository holds a `merge-check.sh`
(ADR 0529).
- **The gate** keeps one key per pull request and the merge's states only: `proposed`, `ready`, `rejected`, `held`,
`merged` and `closed`, read from its `table` verb. On the operator's Approve it merges a delivery group's pull
requests in one pass and writes their merged heads as one revision of `source.main`. It sends nothing to a
machine and asks the controller nothing.
- **The controller** (`mesh-controller`, on the control-node) keeps registration, placement in its assignments
bucket and every node's inputs, and composes: on events it writes each module's signed composition for each node at
each fingerprint into the node's bucket `composed-<node>`, with its digest and its judgement of the move in
`mesh-controller_composed`, and it issues a module's credential and grant when its build is done. It plans,
orders and sends nothing: of its seat's 65 verbs, 20 are refused by name with their replacement, among them
`plans`, `plan`, `push`, `deliver`, `delivery-stop`, `delivery-order` and `delivery-walks` (read 2026-10-26 01:20).
- **The assigner** writes each node's want of each module, `want.<node>.<module>`, naming a fingerprint and the
digest of the one composition it allows, when its seven rules hold: the build is done, the fingerprint is not
held, the node is the module's canary or the canary runs it healthy, at most `step` others ahead, every provider
runs healthy, the grant exists and the composition exists. In the code the canary is the first of the module's
nodes by name that is online (one ring), and rule 4 always holds, because the mesh keeps no `step`. It also runs
the composition check the checker reads. Its differences bucket, from release 5's comparison with what the
controller sent, is no longer written.
- **Two ways around the rules, both by hand.** A **put** (ADR 0546), written by the assigner's tool `assigner_put`,
puts one version of a module on one node: while a put owns a node's want of a module, from its ask until it ends,
the rules pass over that want. And the operator's credential may write a want itself, with a composition the
operator signed and wrote through mesh-sdk's `mesh-hand-compose` (ADR 0539 §10).
- **The node agent**, a process of the node-engine (`mesh-host`) on every node, watches its node's wants and its
bucket of compositions, and applies a module only when the want's fingerprint or digest differs from what runs,
and only the composition whose digest the want names, after checking its signature. It asks its node's
`node-backup` seat to back up a data module first and waits for the backup's end. It writes the have,
`have.<node>.<module>` in `node-engine_have`, with the fingerprint, the composition's digest, the phase and the
health. A failed apply is retried by the next change of the want or the composition; an apply that failed only
because the bus did not answer for a moment is retried with a bounded backoff, and at once when the bus is back.
- **The watchman** mirrors every want as a pulse owed by that node's agent until its have names the want's
fingerprint, so a node that does not move shows as a stall.
- **The status view** derives from `source.main`, the wants and the haves whether the source revision is
delivered, and says the event `delivered` once per revision, and `moving`, `settled` and `stuck` once for a
module on a node at one composition. The messenger holds the operator's notices on those events, and the issue
tracker hears `delivered`.
- **The grants part** (`mesh-grants`, on the control-node; ADR 0547) publishes each assignment's membership on the
assignments stream when it differs from the one last published. The bus's user list, assertions and grants it
composes only in its own state, compared with what the bus holds, and they take no effect; the controller still
composes the user list the bus loads.
- **mesh-delivery** owns deliveries and delivery groups (ADR 0239): one delivery per commit, a group per branch
name shared across repositories. It records each transition in its state on the bus (key-value buckets
`deliveries` and `groups`), marks pull requests, asks the controller to deliver, and merges a pull request marked
ready at its head once both required checks pass against the trunk's head (ADR 0364) and the operator approved
the merge into main on a channel that proves who answered (ADR 0398 §3).
- **The controller** plans, orders, sends and judges. It computes a delivery plan for a repository and the files a
merge changes, moves only modules whose build source changed with their build dependents (ADR 0267), and
reuses a merge check's build of the same reuse key (ADR 0350).
- **The builders** (holders of the build seat) take build requests and checks from a work-queue stream on the bus
and build inside a toolchain image another module provides, with the network off; on 2026-10-13 no node had Go
or npm installed (issue 626).
- **The forge module** publishes every forge event on the bus through the forge's system webhook since 2026-10-14
(issue 729), and still polls the forge every 30 s, or every 300 s once it hears events. It keeps files of what it
announced so it does not announce twice.
## From a merge to the nodes
## From a merge to a rollout
- A pull request's head is built and checked as it comes in; one status, `mesh/check`, carries the verdict, and a
delivery group has one verdict for every member. A move of the trunk asks the checks again, and a running check
stops itself when its head or trunk commit moves.
- The gate merges on the operator's Approve and moves the source revision in one write. Nothing collects merges or
waits for others: the indexer writes the new fingerprints, the builder claims each new one once, the controller
composes and grants on the done build, and the assigner writes each want once its rules hold, the canary first.
- Each node applies its own wants one module at a time and writes its haves; the source revision is delivered when
every want equals its module's fingerprint and every have equals its want, running and healthy. Read on
2026-10-26 at 01:20: 303 wants, every one equal to its node's have in fingerprint and composition digest, and 12
fingerprints held.
- **hq** builds no module: the checker runs hq's Go tests (`go test ./...`) as its check (ADR 0527).
- **The merge window.** The controller collects merges into an open batch; each merge restarts the window, which
closes after 90 seconds without a merge or 10 minutes after the batch's first merge (ADR 0276). A lone merge is
cut at once (ADR 0282 §2, as ADR 0304 §1 changed it). The batch and its merges are rows in the controller's
store, cut by a timer every 5 s, with a catch-up timer every 5 minutes for merges no batch holds.
- **The rollout.** A closed batch becomes one rollout, at most one commit per repository, tiered along the module
graph. Rollouts run one at a time: a batch cut while a rollout is open is queued behind it, whatever it shares
with it. Running unrelated rollouts side by side (ADR 0282 §3, ADR 0304 §2) is decided and not built (issue 525). A rollout's builds go to every machine at once, with no first-machine gate, and the rollout ends on
the machines' reports or after fifteen minutes of silence (ADR 0360). A controller timer advances rollouts every
30 s.
- **A merge during a rollout.** Its place in the next batch is a store row the open rollout's holder writes. On
2026-10-16, when another controller worked the plans, a merge was not recorded and waited until a person released
it (issue 921).
- **"Release branches".** A pull request into a branch other than a trunk is judged by the controller built from that
branch (ADR 0358); feature 57 shipped about 37 packages that way over about 2.5 days and held the controller for
55.7 h.
## Checks
- Every pull request's head gets `mesh/merge-gate` (the composed machines) and `mesh/repo-check` (the repository's
`merge-check.sh`), each judged against one trunk commit (ADR 0323). A trunk move asks the check of every active,
cleanly merging pull request again (ADR 0395).
- A delivery group's members are each checked beside their siblings' heads, and the group also gets a composed
check, `mesh/delivery-group`; each member keeps its own gate beside it (ADR 0382).
- The queue's order (delivery builds first, then ready checks, then the rest) is kept by pausing the worker and
deleting and republishing a request (issue 558); an old head's check is ended after asking the forge for the
current head (issue 726).
- mesh-delivery ticks every 10 s, reconciles with the controller every 30 s and polls the forge for ready marks
every minute, although the forge module already publishes labeled, commented and synchronized events.
## Where the record of what runs lives
Fingerprints, builds, verdicts, wants and haves are keys in the parts' buckets on the bus; compositions are in each
node's `composed-<node>` bucket, readable only by that node, with a file over 64 KiB in the object store; builds'
files are in the artifact store by digest; packages are versions in the package registry. The controller
still records every build it takes in, in its builds bucket (`RecordBuild` in `cmd/mesh-controller/key_takein.go`); its
assignments bucket keeps placement alone, and its rollouts bucket keeps the record of releases 1 to 5 read-only, and
nothing writes it now.
Builds and build requests, rollouts with their tiers, batches and merges, and assignments are rows in the
controller's store; artifacts are blobs in the artifact store by digest (ADR 0251); packages are versions in the
package registry. The controller writes three record mirrors to the bus: assignments, declared config and
predefined keys; of the five ADR 0289 §7 decided, the nodes, rollouts, builds and settings mirrors are not in the
code. An assignment names its module and node but not the build, artifact or rollout that put it there: which commit
runs where, and which delivery brought it, takes three verbs and a join by hand.
No backup is taken before a delivery moves a module that holds data; backups run nightly and on demand (ADR 0214).
## What is not covered
Nothing checks the delivery budget: the over-budget condition and the gate's `times` went with release 6, and no
reader of the view's `settled` and `delivered` events measures it yet (issue 1945, note 10). The bus's own move stays
a person's step through the controller's `bus` verb (ADR 0539 §15). The checker's left-running keys of build modules
that stopped (`checker_status`'s `left_running`) are listed and hold no place. There are no end-to-end tests of
delivery, and the lab is not used (the operator, 2026-10-16).
## How it is checked
- `mesh-delivery-view.assigner_wants` and `mesh-delivery-view.node_haves`: each want beside its have; `indexer_modules`
and `builder_builds` for the fingerprints and builds they name.
- `mesh-delivery.table`: the gate's states and transitions; `mesh-delivery.deliveries` and `show` for one delivery.
- `mesh-controller.seats` and `mesh_overview`: the controller's verbs, and which are refused by name.
- The code: `mesh-delivery` (`modules/mesh-delivery-assigner/internal/assign`, `modules/mesh-delivery-view/internal/view`,
`modules/mesh-delivery-watchman/internal/watch`, `modules/mesh-grants`), `mesh-host` (`internal/agent`,
`cmd/mesh-host/nodeagent.go`), and the gate in `mesh-catalog` (`modules/mesh-delivery-gate`).
The integration tests run only in each repository's merge check; there are no end-to-end tests of delivery yet,
and the lab is not used (the operator, 2026-10-16).
+47 -131
View File
@@ -1,153 +1,69 @@
---
layer: as-is
status: implemented
code: [mesh-tools, mesh-host, mesh-controller]
updated: 2026-10-26
code: [mesh-catalog modules/mesh-console, mesh-tools src/mesh.ts, mesh-tools src/http.ts, mesh-tools src/runtime.ts, mesh-controller internal/broker, mesh-controller cmd/mesh-controller/check.go, mesh-controller cmd/mesh-controller/seatverbs.go]
updated: 2026-10-10
decisions:
- "ADR 0539"
- "ADR 0360"
- "ADR 0524"
- "ADR 0525"
- "ADR 0152"
- "ADR 0175"
- "ADR 0195"
- "ADR 0197"
- "ADR 0367"
- "ADR 0095"
- "ADR 0037"
- "ADR 0154"
---
# The mesh MCP server, as it runs
**The `mesh-console` container no longer exists.** Read 2026-10-17 from `mesh-tools` (package
`node-tools`), `mesh-host` and `mesh-controller` on their forges' main, and from the live mesh
through `mesh_overview`, `mesh_machine`, `mesh_runtimes` and `mesh_describe`. Checked: the
catalogue (`mesh-catalog`) carries no module named `mesh-console` any more — the name survives only
in the glossary's retired-words list and in a test fixture — and none of the four nodes answers a
module of that name. How the tool runner reaches a node, its count of modules and tools and the
controller's verbs were read again on 2026-10-26, after the delivery of release 6 (ADR 0539).
## What it is now
**The tool runner is one of the mesh's core components**, named `node-tools` beside
`mesh-controller`, `mesh-host` (the node-engine) and `nats` (the bus) — the mesh's own repositories'
check treats the four the same way, as the core whose health the mesh itself defines rather than a
module's own. Since release 6 it reaches each node the way every module does: the controller composes
and signs its composition for that node, the assigner writes the node's want of it, and the
node-engine's node agent applies it and writes its have. What stays particular to it is the account
named `<node>.node-tools` on the bus (`BusNodeTools` in `mesh-controller`) and the node-engine's
witness and lease code that puts back a tool runner that does not come up (`ComponentNodeTools` in
`mesh-host`, ADR 0360 §9).
**It still carries a manifest, `node-tools/module.json` in `mesh-tools`,** and that manifest is
where its grants live: `invokes` names `*`, so its account may call every tool on the mesh;
`waits` names the five event families `mesh_wait` may subscribe to (the knowledge base's reviews,
mesh-delivery's transitions, the forge's status, the controller's checked event, and the operator
channel's decision for the agent module) — the bus refuses a subscription outside this list
(ADR 0367 §2); and it
`provides` one provision, the MCP endpoint, at node scope, and `listens` on the port it serves from,
opened only to the machine itself. Its code lives under `node-tools/` in the `mesh-tools`
repository (`internal/console`, `internal/announce`, `internal/bus`).
**One runtime per node, run by the node-engine as a sibling process, never a container**
(ADR 0175). It holds the node's one bus credential and may call every tool on the mesh; its account
publishes nothing else. It binds `127.0.0.1:<port>` and refuses to bind anything else, said before
binding rather than after (`node-tools/internal/console/http.go`, citing ADR 0152): whoever is on
the machine owns the mesh there, and nobody else may reach this port. An agent or a person on the
node is pointed at `http://127.0.0.1:<port>/mcp` once; the `serverInfo.name` the handshake answers
is `mesh`.
**The mesh's tools reach a person through a module the mesh assigned to their machine.** Since
2026-09-30 a workstation that is a node can be assigned `mesh-console`; the mesh mints a bus account
`<node>.mesh-console`, seals its credential to the machine, and the container binds
`127.0.0.1:<port>` with the port the mesh assigned for the manifest's declared one. An agent on the
machine is pointed at `http://127.0.0.1:<port>/mcp` and sees the mesh's tools; a person uses the same
endpoint. Nothing on the machine holds a credential a person had to carry.
## What it answers
`initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`, over HTTP, one JSON
body per request, no session and no event stream (`node-tools/internal/console/mcp.go`).
`initialize`, `tools/list`, `tools/call`, over HTTP, one JSON body per request, no session and no event
stream. `tools/list` is what the running modules answered: every tool runner built on or after that day
serves a `tools` verb for its module, and the mesh MCP server asks the catalogue for the roster and each module
for its tools. A module that did not answer is named in the list's `_meta.notAnswering`. On the day it
shipped that was 36 of 51 modules — those that serve no tools at all, and those whose rebuilt runtime the
mesh records rather than rolls out — and 62 tools from the rest.
**It announces seven tools, not five** (ADR 0195, extended by ADR 0367): `mesh_overview`,
`mesh_machine`, `mesh_search`, `mesh_describe`, `mesh_call`, `mesh_runtimes` and `mesh_wait`. The
handshake's `instructions` text names all seven and the four address forms. `mesh_overview` is
where the mesh-wide count of modules and tools is read live — a number copied into this document
would drift; asked on 2026-10-26 it answered 115 modules and 714 tools across the whole mesh, with
two module instances (`route-proxy` on two of the nodes) named as not answering rather than silently
absent.
`tools/call` reaches any tool by `<module>.<tool>`, listed or not. The mesh MCP server's grant is `*`, so what it
may call is every tool on the mesh; its account may publish nothing else and subscribes nothing.
**Addresses, exactly as the handshake states them:** `<seat>.<verb>` for a seat held once for the
mesh (`mesh-controller.status`); `<node>/<seat>.<verb>` for a seat every machine holds
(`<node>/node-packet-filter.rules`); `<node>/<module>.<tool>` for a module assigned to one machine;
and `<module>.<tool>` also for a module whose instances are interchangeable. A tool not listed can
still be called by address — listing is discovery, calling is the grant — and a module that is not
interchangeable is refused with the machines it runs on rather than guessed at.
**What exists is what announced itself** (ADR 0197). Every node's tool runner answers the NATS services
protocol's `$SRV.PING`, `$SRV.INFO` and `$SRV.STATS`, one service per process, naming in each
endpoint's metadata which module, seat, scope and machine serves it. `mesh_overview` and
`mesh_machine` gather one request's answers: at least 750ms so a runtime that only just answered
`$SRV.PING` is still waited for, up to 5s for one that said it is there but has not yet sent its
full announcement — a fixed shorter window lost the largest runtime's listing once a live
measurement found how slow its round trip over the bus could be. An announcement unchanged since
last asked (by its digest) is not re-fetched, which is why `mesh_runtimes` can show a runtime
answering in under 100ms for hundreds of kilobytes of discovery.
## mesh_wait
**The seventh tool, added by ADR 0367**, lets a session wait on the bus for an event instead of
polling: a call names one or more granted event families and an optional filter, and either answers
the first match or says it timed out. The tool runner's own bus account hears only the families its
manifest's `waits` list names; an event it hears is never taken as the state — a session reads the
state from the verb that owns it once woken. It is meant to be called in a loop from a background
command, not inline, because one call blocks until it answers or times out.
No call is refused for want of a person's approval: a tool runner serves every call it is issued, whoever made
it, and cannot tell an agent from the operator (ADR 0259 §6, issue 340). An act waits for the operator's warrant
only where its module asks first, in its own code: the agent module's instruction proposals, and the
controller's settings proposals and the actions it offers on a condition.
## The mesh's own verbs
**These already answer directly, not as a stated future.** Beside every assigned module's tools,
the tool runner asks the `mesh-controller` seat for its own verbs the same way (ADR 0154): measured
live on 2026-10-26, the mesh-controller runtime is one instance, mesh-wide, and it answers 65 verbs for
that one seat (`status`, `assign`, `modules`, `artifacts` and the rest), of which 20 are refused by
name with what replaces them: the delivery verbs release 6 removed (`plans`, `plan`, `push`,
`deliver` among them) and the build verbs of release 4 (`build`, `queue` among them). The `mesh-controller`
*module* answers none of its own — it serves no module tools, only its seat's, exactly as design 33
described while it was still only designed. The 56-to-93 seat-verb counts each node's tool runner
answers (below) are a different thing: the tool runner serves the verbs of whichever node-scoped
seats are held on that node (`node-backup`, `node-build-go`, `node-service-manager` among them),
on behalf of the modules holding them, not the mesh-controller seat's — that one is served by the
mesh-controller runtime itself, a separate process, and the tool runner only asks it and relays what
it answers.
*Since 2026-09-30 evening (ADR 0154).*
The mesh MCP server asks the `mesh-controller` seat's `tools` verb beside the modules and lists every role's
tools as `<seat>.<verb>` — `mesh-controller.status`, `mesh-controller.push` and the other ten. A call
to `<prefix>.<name>` reaches the seat when the prefix is a seat declaring that verb, and the module
otherwise; `seat:<seat>.<verb>` says so outright. When the controller does not answer, the list
names `mesh-controller (seat)` as not answering and carries the modules' tools regardless. The
`mesh-controller` *module* is always named as not answering: it serves no module tools, only its seat's.
## Where it runs
## Around it
**Every node, not a per-operator assignment.** Measured live: all four of the mesh's machines
answer a `node-tools` runtime, each with its own module and tool count (between 294 and 312 tools,
56 to 93 seat verbs, in this reading). This is a fact that shipped past the design it came from: the
to-be document still describes it as assigned to "whichever machines an operator sits at"; what
actually runs is the opposite — every node the mesh holds carries one, because it is core
infrastructure the mesh itself depends on (discovery, the controller's own verbs, every module's
tools), not a convenience assigned to a workstation. There is accordingly no assign or unassign verb
for it: removing it from a node is not an operation the controller or the catalogue exposes.
- **`invokes`** in a manifest is the grant. It is composed into the bus's user list exactly as a
person's account is; the mesh MCP server is the only module that declares it.
- **`module check <file|dir>…`** on the controller's binary judges a manifest with no mesh: the strict
parse, every per-manifest problem, and the rules between the manifests given. It prints what it cannot
judge without a store rather than refusing. The mesh MCP server's own manifest was the first thing checked
with it, and the whole catalogue passes.
- **The person's client remains.** `operator issue` and `mesh tools|call|mcp` with a credential file
still work, for a machine that is not a node and for a mesh not yet able to assign anything.
`mesh tools --console <url>` goes through a running mesh MCP server with no credential; it is covered by the
runtime repository's tests and was not exercised on the live mesh.
## What this repository's prior text got wrong
## What shipped bent
The document this replaces described `mesh-console` as a catalogue module served by a container and
assigned only to workstations. That module and its container are gone; the manifest is not — it
moved to `node-tools/module.json`, still declares `invokes: ["*"]`, and gained `waits` and the
`mcp-endpoint` provision, described above. What also moved is which language builds it: the
manifest's `build.artifacts` names a Go binary built from `cmd/node-tools`, and that is what a node
runs. The repository still carries the earlier TypeScript sources (`node-tools/src/mesh.ts`,
`http.ts`, `runtime.ts`, `announce.ts`, `broker-nats.ts`, `client.ts`, `launch.ts`, `main.ts`,
`mcp.ts`) on main, but the manifest does not build from them. The quirks the prior text recorded
from the `mesh-console` era (a module registered by hand recording its source as a URL; a
*record*-policy module staying listed as not-answering until its rebuilt runtime is pushed) are not
re-checked here: they were about the `mesh-console` container's registration path, which no longer
exists.
## How it is checked
- `mesh_overview` and `mesh_machine <node>`: the live tool and seat-verb counts, and which modules
are named not-answering.
- `mesh_runtimes`: which runtimes answered discovery, how long they took, how large their
announcement was, and whether it was shortened to fit the bus — read this when an address is said
to be missing or on another machine.
- `mesh_describe <address>` for one tool's schema; a call through `mesh_call` reaches the module or
seat over the bus and the answer is its own, unshaped.
- The code: `mesh-tools` (`node-tools/internal/console`, `internal/announce`, `internal/bus`),
`mesh-host` and `mesh-controller`'s core-component handling of `node-tools` (witness, lease,
bus-user naming). Their unit tests hold the loopback-only refusal, the seven-tool handshake, the
discovery gather window and `mesh_wait`'s event-then-read-state rule.
- `knowledge-base.get` with `kind: decision` and number 152, 175, 195, 197, 367 or 154.
- A module registered by hand from the catalogue with `--source <url> --path modules/<m>` records a URL,
not a place on the git seat: `--self` takes the forge path form (`<owner>/<repository>`), which the
operator did not pass. The rebuild-on-merge matched the URL anyway.
- Modules whose upgrade policy is *record* — the forge among them — answered `tools` only once
something pushed their rebuilt runtime; until then they are listed as not answering while still
callable. That is the policy doing what it says, not a fault of the mesh MCP server.
-1
View File
@@ -5,7 +5,6 @@ code: [mesh-controller internal/catalogue/state.go, mesh-controller internal/bro
updated: 2026-10-04
decisions:
- "ADR 0201"
- "ADR 0439"
---
# A module's state, as it runs
+1 -13
View File
@@ -2,11 +2,9 @@
layer: as-is
status: implemented
code: [mesh-controller, mesh-tools]
updated: 2026-10-19
updated: 2026-10-16
decisions:
- "ADR 0106"
- "ADR 0436"
- "ADR 0438"
---
# Logins on the bus, as they run
@@ -27,16 +25,6 @@ Nothing marks what a tool does.** Read from the controller's and the tool runner
- **The caller is in the address.** A call's address carries the caller's name, so the runtime takes the caller
from the address, never from the message. For a seat every machine holds, the address also names the machine.
- **A module's process may have a login of its own** (ADR 0436, delivered 2026-10-19 in mesh-controller #390 and
mesh-sdk #36). A `process` resource that declares `bus` runs as a user its module makes and gets one login per
machine, named by machine, module and process. It may emit its declared events, take work from its module's own
work-queue stream through the one consumer the controller makes for it, use its module's states, read the
router's records of its module's asks, call the verbs it declares, and listen only on its own inbox. It can
answer no call and hold no seat. `module issue` delivers its password as the module's own secret
`broker-<process id>`, readable by that user alone. mesh-mail's sender is the first such process: it holds the
mail passwords that the tool runner may not read, and sends each mail the operator approved, which the tools
record as queued before they put it on its work queue (ADR 0438).
## What it cannot do
- **No expiry.** A login lives until its record is deleted and the list rewritten on the bus's node.
@@ -1,116 +0,0 @@
---
layer: as-is
status: implemented
code: [mesh-controller, mesh-catalog modules/claude-code, mesh-catalog modules/gitea, mesh-catalog modules/ssh-client]
updated: 2026-10-17
decisions:
- "ADR 0266"
- "ADR 0399"
- "ADR 0409"
- "ADR 0410"
---
# Accounts on a machine, as they run
**A node record names at most two logins: the operator account, always, and an agent account, on one node of
four. Where a node names no agent account, agents run as the operator account. Nothing ties an identity to a
login, and a module that needs one declares it itself.** Read on 2026-10-17 from the controller's and the
catalogue's code on main (both local clones were a few commits behind the forge, so each claim below was
also checked against a live read) and from the live node records. The change planned in ADR 0411 to 0414
replaces this document when it is built.
## The node record
- **The operator account is stated at the controller's terminal** with `node account <node> <account> [home]`.
It is the person's login on that machine. The controller refuses to set it to the node's agent account.
- **The agent account is stated at the controller's terminal only** with `node agent-account <node> <account>
[home]`, and cleared with `--clear`. It is no verb and no setting, so an agent cannot name itself one
(ADR 0266 §1). The controller refuses root, a malformed login, a well-known service account and the
operator account.
- **Five machine facts come from the record.** `account` and `account-home` are the operator's, absent when
the node has no operator account. `agent-account` is the agent account, or the operator account where none is
named; `agent-home` is its home (`/home/<account>` when no home is stated); `agent-root` is `never` only where
the node names an agent account of its own, otherwise empty. Modules read them as `${machine:...}` words.
- **One node of four names an agent account today**, the control node. The workstation, the laptop and the
home server name none: `mesh-controller.node` says there "agents run as the operator account; no agent
account is named", and the plan for the workstation declares the `claude-code` account resource with the
operator's name and no `root` field. The tool runner also receives `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME`.
- **Not verified:** whether the mesh-config snapshot carries either account. The controller's code reads both
from its own node table (columns for account, account home, agent account and agent home); no second place
that states them was found.
## What claude-code does with the agent account
- **It declares a `user` resource** named `${machine:agent-account}` with home `${machine:agent-home}` and
`root: ${machine:agent-root}`, and a `~/.claude` directory under that home owned by it, mode 0700. On the
control node the plan holds the resource with `root: never`; elsewhere it holds the operator's account.
Where the account is new, the mesh creates it with a home, its own group and no password (ADR 0266 §2).
- **`claude-agent`** is a file the module writes at `/usr/local/bin/claude-agent`. Run as the agent account
it starts `claude`; run as any other account it runs `sudo -iu <agent account> claude` through that
account's own sudo, carrying the operator's guard override only if it is set in that shell. The file
`/etc/claude-code/agent-account` holds the account's name.
- **The guard of ADR 0266 §28** is the module's hook on every tool call. On a node with an agent account
of its own it refuses each call of a session that runs as another account, with no override, and tells the
session to start with `claude-agent`. Its record goes to the machine's journal. The same guard also refuses
ssh to a mesh machine and writes to `/etc/hosts` and the resolver file (ADR 0245).
- **Not verified live:** this rests on the guard's code and README, not on a refusal seen on a running machine.
## The judgement that the agent account cannot become root
- **The node-engine judges `root: never` on every look**, by reading only: uid 0, a group that grants root,
a sudo, doas or polkit rule, a container runtime's socket or API port, a secret the mesh placed for another
account, a setuid program no package owns, and kernel link protection off (ADR 0266 §12 to §16). A way found,
or a question unanswered, makes the account resource unhealthy. It does not prevent any way to root.
- **The controller's self-check holds** that a node naming an agent account has it judged, in a verdict heard
within 15 minutes, as unable to become root (ADR 0266 §21 to §22). Otherwise it raises the urgent condition
`agent-can-become-root`. `mesh-controller.node` shows the verdict: for the control node it reads "cannot
become root without a person", judged the same day.
- **On the three nodes without an agent account** the check does not apply. The controller instead holds an
urgent condition `root-not-free` on each machine that runs reads or the mail notifier. Two of the three are
silenced until 2026-10-23 on the operator's decision; the home server's is open and not silenced.
## The accounts agents push as on the forge
- **Today the forge has a user per machine**, made by the gitea module's `forge-account` provision and
holding that machine's key, plus a write token the module still mints for each (ADR 0398, ADR 0400). ADR 0410
decides one forge user, `nox-agent`, in the agents' write team, each machine's key titled by machine. No tool
reads the forge's user list, so whether `nox-agent` exists yet is **not verified**.
- **The forge key is meant for the agent account** (ADR 0409 §2), but a module cannot run as that account:
the controller refuses it (issue 989, open). So the `ssh-client` module's key keeper still runs as the
operator account.
- **An ordering deadlock** between `ssh-client`, which wants `forge-account`, and `gitea`, which provides it,
stopped every node's declaration from composing on 2026-10-17 at 00:35 until gitea was registered three
minutes later (issue 986). The four urgent conditions it raised were not observed again but stay raised,
because the controller's declaration check times out and never clears them (issue 992).
Git over ssh was proven from the laptop only; the proof on the other three, and retiring the forge token the
module still places, wait on issues 989 and 991 (the latter in `fixing`).
- **Main stays closed**: a push to the main branch is refused, and a merge waits for a person (ADR 0398).
## Logins on the bus
Not here: see [`16-logins-on-the-bus.md`](16-logins-on-the-bus.md).
## What it cannot do
- **No identity carries a login.** An agent, a person and a module are not tied to an account on a machine;
the node record names a login per node and nothing else.
- **A module declares a login itself.** Each module that needs an account writes a `user` resource of its own,
and nothing checks that two modules agree or that a login exists on every machine that needs it.
- **Agent and operator share a login on three of four machines.** There the agent runs as the operator account,
which may become root; the guard's account rule does not apply, and the exposure is a held condition, not a fix.
- **No per-agent bus password.** Every session on a machine calls through that machine's tool-runner login
(document 16), so one agent's calls cannot be told from another's.
- **Detects, does not prevent.** The judgement above lists the ways to root it looks for; a way it does not list
is not seen (ADR 0266 §17).
## How it is checked
- `mesh-controller.node` with a node name: "agents run as" line, the verdict and when it was judged, the open
conditions (`agent-can-become-root`, `root-not-free`, `uncomposable`).
- `mesh-controller.plan` with a node name: the `claude-code.agent-account` resource and its `root` field; with
`files` set, the files given, including `claude-agent` and `agent-account`.
- `mesh-controller.conditions` for the held conditions; `claude_code_guard` for the guard's record, `claude-code`
tools for its status.
- `knowledge-base.get` with `kind` decision and number 266, 409 or 410; `issue-tracker.get` with 989 or 991.
- The code: the controller's node-record and agent-account files, and the `claude-code` module's manifest and
guard in the catalogue. Their unit tests hold the node record's refusals and the guard's account rule.
-286
View File
@@ -1,286 +0,0 @@
---
layer: as-is
status: implemented
code: [mesh-catalog modules/mesh-mail, mesh-catalog modules/mesh-mail-web, mesh-catalog modules/mesh-mail-notifier, mesh-catalog modules/mesh-mail-reader]
updated: 2026-10-22
decisions:
- "ADR 0359"
- "ADR 0361"
- "ADR 0378"
- "ADR 0379"
- "ADR 0436"
- "ADR 0439"
- "ADR 0440"
- "ADR 0459"
- "ADR 0461"
- "ADR 0472"
---
# The mail modules, as they run
**One module, `mesh-mail`, holds every mail account and is the mesh's one `mail-store`. A second module,
`mesh-mail-web`, is the mesh's one mail web client. A third, `mesh-mail-notifier`, shows a
desktop notice of new mail on the machines it is assigned to. A fourth, `mesh-mail-reader`, holds the seat
`mail-reading`: it hears every new mail, reads it in small batches, and keeps a mail entry — its mail group, a
one-line summary, its mark and why, never a body — for the mail page to show; the operator's own corrections
on the mail page become reading lessons that outrank a mail's own words in a later batch. A caller may read an
account's mail, or ask to send from it, only when `mesh-mail`'s trusted setting `grants` names it for that
account — one list per account, giving both at once, to the same callers; `mesh-mail-reader` is on that list
too, and asks no send, by its own design rather than by a narrower grant. A send is never performed by a tool:
it is queued for a process of its own, which sends only once the operator approves it on Telegram.** Read
from `mesh-catalog` on `origin/main` at `835bf22b` (2026-10-22) and from the live mesh through the mesh MCP
server on 2026-10-22.
```
mesh-mail (mail-store, once for the mesh — the control-node)
accounts search read send send-status ── MAIL stream (headers only, 7 days) ──▶ a granted caller
own database (index) · own object-store bucket (attachments) · three key-value buckets (sends, account state, newest mails)
process keep (IDLE + index) · process watch (mail_health) · process send (mesh-mail-send account; holds the passwords)
▲ grants: one list per account, read AND send alike (mesh-mail-reader on it too, asking no send)
│
┌────┴────────────────┬─────────────────────────┬──────────────────────────────────┐
mesh-mail-notifier mesh-mail-reader mesh-mail-web (mail-webclient,
(mail-notifier, once (mail-reading, once for once for the mesh — the control-node)
per machine — laptop, the mesh — the control- sign-in (Keycloak) ─ pages ─ send ask ─▶ operator's Approve
workstation) node) ▲ (Telegram)
node-notifier.send on hears MAIL's added events; │ entries groups correct
new inbox mail batches of 10 judged ─▶ its own │ lessons undo status
database (entries, groups, lessons) ─▶ ─┘
mail signal ─▶ operator's channel (Telegram)
```
## 1. The modules and their seats
- **`mesh-mail`** claims `mail-store`, held once for the mesh (`accounts`, `search`, `read`, `send`,
`send-status`). It is assigned today to one machine, the control-node (`module.json`; confirmed live,
2026-10-20). ADR 0378 §4 decided to move it to the home server as a step of its own, on the operator's
Approve (issue 803); as built, that move has not happened: the live mesh still runs it, and `mesh-mail-web`
beside it, on the control-node.
- **`mesh-mail-web`** is a module of its own, with its own Go module and source under `modules/mesh-mail-web`;
it claims `mail-webclient`, held once for the mesh. It is assigned to the same machine as `mesh-mail` and runs as its
own account, `mesh-mail-web`. Its manifest declares no database and no store of its own: nothing beyond its
route, its sign-in binding and its own settings on disk.
- **`mesh-mail-notifier`** is a module of its own as well (`modules/mesh-mail-notifier`), and keeps a
byte-identical copy of `mesh-mail`'s `mailevent.go`, held to it by its `copies_test.go`, because each module
builds alone. ADR 0378 §9 and ADR 0440 §1 say both are "built from `mesh-mail`'s source"; as built, they are
separate modules that share none of `mesh-mail`'s own code but that copied file. It claims `mail-notifier`, held once per machine; a
module claiming it needs `node-notifier` held on the same machine (ADR 0378 §7). It is assigned today to two
machines, the laptop and the workstation (live read of each machine's modules, 2026-10-20); the control-node
and the home server hold no instance of it.
- **`mesh-mail-reader`** is a module of its own (`modules/mesh-mail-reader`), with its own Go module and
source (`go.mod`, `cmd/mesh-mail-reader`), importing nothing of `mesh-mail` (`callers.go`). It claims `mail-reading`, a fourth seat `mesh-mail`'s own manifest declares beside its three mail
seats, held once for the mesh (ADR 0459 §1, §2). It is assigned to the same machine as `mesh-mail`, under
its own account, `mesh-mail-reader` (`runs-as`; confirmed live, 2026-10-22). It is covered whole in §8.
- None of the four seats is named `mesh-*`. A caller reaches them by seat (`mail-store.<verb>`,
`mail-webclient.<verb>`, `mail-notifier.<verb>`, `mail-reading.<verb>`); `mesh-mail` also answers its own
tool names (`mail_accounts` and the rest) through the same handlers.
## 2. Accounts and the MAIL stream
- **Three accounts**, all of the kind `imap`, each a password-based sign-in given at the desk as the module's
own secret `mail-credential-<account>` — one against the mesh's own mail server, two against outside
providers (ADR 0359 §5, ADR 0361 §1; `mesh-controller.settings` for `mesh-mail`, read live on 2026-10-20). No
account's name or address is repeated in this document.
- **It watches each account and says each mail on the bus.** `mesh-mail` keeps IMAP IDLE on the inbox and polls
the other folders; for each new, changed or removed message it publishes an event on its own stream `MAIL` —
account, folder, message id, date, from, to, subject, size, flags and attachment references, never a body or
an attachment's bytes (ADR 0359 §2). The stream keeps 7 days (`module.json`).
- **A send's own record** lives in its state bucket `sends`, which only the `send` process and the tools write
(`module.json`; ADR 0436 §8).
- **The index and the attachment store are its own.** A row per message and folder lives in its own Postgres
database; attachments live in an object-store bucket, each kept once by its SHA-256 digest (ADR 0359 §3, ADR
0439 §5-§6).
- **Two more buckets answer without the database.** An account bucket holds each account's state and, per
folder, its name and its mail and unread counts; a mail bucket holds the newest mails of each account (up to
200, fewer once there are more than 81 accounts), for a view that need not ask the database (ADR 0439 §2-§4).
`mesh-mail-web`'s accounts page reads the folders from this bucket — a read wider than any one caller's grant
— but only for an account its own grant already let it ask about (ADR 0440 §4).
## 3. `grants` — one list per account, giving both at once
- **The setting.** `mesh-mail`'s trusted setting `grants` names, per account, the callers that may reach it: a
module's name alone (granted wherever it runs), `<machine>.<module>`, or a person. Nobody is granted an
account by default (ADR 0359 §4).
- **One check, every door.** The code that decides whether a caller may read an account's mail (`mail_search`,
`mail_read`, the account's share of `MAIL`) and the code that decides whether it may ask to send from it
(`mail_send`) is the same function, reading the one list (`modules/mesh-mail/cmd/mesh-mail/config.go`'s
`Grants.Allows`, called from `tools.go` for every tool, `mail_send` included, confirmed on `origin/main` at `a67f0196`). There is no
second list and no narrower grant for sending alone: a caller named for an account may do both, and a caller
left out of it may do neither.
- **Who is granted, today, for every account alike** (`mesh-controller.settings` for `mesh-mail`, read live on
2026-10-22): `mesh-mail-notifier` (named alone, so wherever it is assigned it may read); each of the four
machines' own `node-tools` — the control-node's, the laptop's, the workstation's and the home server's, which
is how an agent session on any of them reaches a mail account at all; the control-node's `mesh-mail-web`; and
the control-node's `mesh-mail-reader`. So the notifier, every machine's agent sessions, the web client and
the mail reader can each both read an account's mail and ask to send from it, from the one list — ADR 0472
§1 and §3 decided the mail reader joins this same list, rather than a narrower one of its own, and that it
simply never calls a send verb: its manifest invokes `seat:mail-store.read` and `seat:mail-store.accounts`
alone, no send verb of `mail-store` (`modules/mesh-mail-reader/module.json`, confirmed on `origin/main` at
`835bf22b`).
## 4. The send path
- **A send is an ask, never a write by the tool.** `mail_send` (account, to, subject, body) is open only to a
caller the account's `grants` names. It performs nothing itself: it raises an ask on the operator channel
naming the account, the recipients, the subject and the first lines, and returns the ask's id at once (ADR
0361 §2).
- **The operator's Approve queues it once.** On an Approve recorded on a channel that proves who answered,
`mesh-mail`'s tools record the send `approved` and publish it, once, on the work queue `mail-sends`, carrying
only the send's id, its account and its ask id — never the recipients, subject or body, which stay in the
send's own record (ADR 0436 §8).
- **A process of its own performs it.** `mesh-mail`'s process `send` runs as its own account, `mesh-mail-send`,
never the tool runner's account: it alone reads the accounts' passwords. It takes work from the queue,
verifies the operator's warrant again against the router's own record rather than trusting the queued message,
claims the send once, hands it to the account's outgoing server, and records the outcome — retrying a send the
server provably never took, up to five times, each after a longer wait, before giving up (ADR 0436 §2, §7,
§9-§10).
- **`mail_send_status` answers one of a fixed set of states**: waiting for the operator, approved and waiting
for the sender, being sent, waiting for its next attempt, sent, failed (with its reason), refused, not proven
(the handover may or may not have reached the server), declined, expired, or unknown for an id it does not
know or that belongs to another caller's send (`send.go`, `sender.go`; ADR 0436 §12).
## 5. `mesh-mail-web`
- **Its pages**, all behind sign-in: the accounts page (`/`), a folder (`/folder`), search (`/search`), one
mail (`/mail`), an attachment download (`/attachment`), writing a mail (`/write`), its send (`/send`) and its
state (`/send-status`), a mail correction (`/mail-correct`) and a reading-lesson undo (`/mail-undo`) — plus
the sign-in callback (`/signed-in`). Every changing form (the send, a correction, an undo) carries a token
bound to the session (`server.go`, `csrf.go`).
- **The accounts page now opens on mail reading, above the classical list** (ADR 0459 §13, issue
1430, 1523; `accounts.go`). It asks `mail-reading.status` first; not answering at all makes the whole
section say mail reading is unavailable and why, and nothing else here is asked — the classical list below
still renders regardless. Otherwise it shows, in order: the mail that matters (`mail-reading.entries`, mark
`matters`), each with a correction form (mark, mail group) that posts to `/mail-correct`; the mail groups
(`mail-reading.groups`) when mail reading is healthy, or the unhealthy cause and since when in their place
when it is not; the scam warnings (the same `entries` answer, mark `scam warning`), each with the same kind
of correction form, shown either way; the reading lessons (`mail-reading.lessons`, newest first), each with
its own undo form posting to `/mail-undo`; and the weekly reading-lessons-per-100-read-mails measure, from
`mail-reading.status`'s own answer. A mail body never reaches this section — only a mail entry's headers,
mail group, summary, mark, why and due date.
- **A correction or an undo is asked of `mail-reading`, not acted on here.** `/mail-correct` and `/mail-undo`
are refused with no sign-in or a form token from another session, before `mail-reading.correct` or
`.undo` is ever asked; whatever `mail-reading` answers or refuses, the page is simply shown again, so a
refused correction is never surfaced as its own error (`mailwrite.go`, issue 1523).
- **Sign-in is Keycloak's.** The module requires `oidc-client` and uses the authorisation-code flow with PKCE;
the identity (the ID token's own username claim) must be in the trusted setting `operators` — nobody by
default, and today one identity, the operator's own (ADR 0440 §3; `mesh-controller.settings` for
`mesh-mail-web`, read live on 2026-10-22). The session cookie is `Secure`, `HttpOnly` and `SameSite=Strict`,
ending 12 hours after sign-in (`auth.go`).
- **Reachable only from the mesh's own machines.** Its route's reach is set to `internal`, so the route proxy
composes only its internal name, never a public one, and the module itself answers any other host 404 (ADR
0440 §2; `server.go`).
- **A mail is shown as text.** `mail_read`'s own HTML-to-text is rendered through `html/template`'s escaping,
never parsed as markup; an attachment is answered only as a download, `Content-Disposition: attachment` and
`X-Content-Type-Options: nosniff`; every page carries a content security policy allowing nothing from
elsewhere (ADR 0440 §5; `mail.go`).
- **It keeps no database of its own.** Nothing on disk beyond its own settings; it reaches mail only through
`mail-store`'s verbs, calling as `<machine>.mesh-mail-web`, and reads one thing directly besides —
`mesh-mail`'s account bucket, for folders and counts, of an account its own grant already let it ask about
(ADR 0440 §4, §6). Its manifest now also invokes `mail-reading`'s six verbs (ADR 0459 §13): it
reads `entries`, `groups`, `lessons` and `status`, and writes a correction or an undo with `correct` and
`undo` — writes that land in `mail-reading`'s own database, never in anything `mesh-mail-web` keeps itself.
## 6. `mesh-mail-notifier`
- **One notice per new inbox mail, at most once.** For each account it is granted, on hearing `mesh-mail`'s
`added` event, it shows the sender (name and address), the subject and the account's name through the
machine's `node-notifier`, under its own account (`runs-as`), never the tool runner's.
- **Quiet rules.** It shows nothing for a mail outside the inbox, for a mail from the account's own address, and
for a mail already flagged read when it arrives (`notifier.go`, `mailevent.go`). During an account's first sync
or a resync `mesh-mail` publishes no `added` events, so nothing reaches the notifier then (`notifier.go`'s
opening note, citing `mesh-mail`'s `imap.go`).
- **Folding.** More than three qualifying mails of one account within a trailing 60-second window become one
notice, shown once the window has passed since the fold's own first mail, naming how many and the latest
sender; a message already shown — tracked by its message id, or by its folder, UIDVALIDITY and UID where it
has none — is never shown twice, even redelivered. A notice that fails to send is retried at most once a
minute (`notifier.go`, its constants `FoldOver`, `Window`, `RetryBackoff`).
- **Assignment.** It is assigned today to the laptop and the workstation (live read, 2026-10-22; `mesh_machine`
for each shows its seat `mail-notifier` and tool `mail_notifier_health` there, and neither on the
home server or the control-node), where it needs `node-notifier` to be held beneath it. ADR 0461 §7 names
these same two machines as the ones it is to be unassigned from, on the operator's Approve, once mail
signals (§8) are proven live on the operator's real accounts (issue 1418) — as built, that unassignment has
not happened.
## 7. Health
- **`mesh-mail`**'s liveness is its `keep` process's own unit health; its watching is judged once a minute by
its `watch` process and read through the tool `mail_health`: `waits` (naming the secret) for an account whose
password was never given, `unhealthy` (naming the account and why) for a connection down or an IDLE silent
for five minutes, healthy otherwise; its `send` process is a plain unit (ADR 0359 §6; `health.go`).
- **`mesh-mail-web`**'s own tool `mail_web_health` reads the one state its HTTP endpoint keeps: still setting up
sign-in, listening, or ended with its error — unhealthy whenever it is not listening (`main.go`).
- **`mesh-mail-notifier`** declares no process resource: its manifest declares only its account and its state
directory. Its bundle is the one process the machine's runtime launches for it and keeps alive, under its own
account (`runs-as`); it serves its tools and runs the watcher beside them (`main.go`'s opening note; commit
12d78520, issue 859). Its tool `mail_notifier_health` is unhealthy once an account's notice
has failed to show for more than ten minutes, and answers only when asked; no check of the node-engine judges
the module by it. A watching process of its own is tracked by issue 841, not yet decided (its `README.md`).
The comment in its `health.go` that still names a process `notify` predates that commit. It keeps one state
bucket, `shown`, for 30 days, to show no mail twice (`module.json`).
## 8. `mesh-mail-reader`
- **It claims `mail-reading`**, the fourth mail seat, held once for the mesh, serving six verbs:
`entries`, `groups`, `correct`, `lessons`, `undo` and `status` (ADR 0459 §1, §2; `module.json`,
`tools.go`). Each answers only a caller the trusted setting `callers` names — a bare module name, granted
on any machine, `<machine>.<module>`, or `person.<name>`, the same shapes `mesh-mail`'s own `grants`
matches, copied in miniature so this module never imports `mesh-mail`'s code (`callers.go`). The setting
defaults to `["mesh-mail-web"]` and is unchanged on the live mesh (`mesh-controller.settings` for
`mesh-mail-reader`, read live on 2026-10-22), so only `mesh-mail-web` may call a `mail-reading` verb today;
any other caller is refused, naming why.
- **Its own account and database.** It runs as its own account, `mesh-mail-reader` (`runs-as`), never the
tool runner's, because it hears `mesh-mail`'s restricted stream `MAIL` under it. It is granted reading on
every account `mesh-mail` holds — named as the control-node's `mesh-mail-reader` in `mesh-mail`'s `grants`
for each of the three accounts alike (`mesh-controller.settings` for `mesh-mail`, read live on 2026-10-22) —
and never asks to send: its manifest invokes `seat:mail-store.read` and `seat:mail-store.accounts` only, no
send verb, and its code calls neither `mail_send` nor anything that moves, flags or deletes a mail (ADR
0459 §11, ADR 0472 §3). It keeps its own Postgres database (`postgres-database`, contributed as
`mail-reader`), separate from `mesh-mail`'s index.
- **Its database holds four tables, no mail body in any of them** (`schema.sql`): `mail_entry` — one row per
judged mail, its account, message id, folder, date, sender name and address, subject, its `mail_group`, a
`summary` of at most 160 characters, its `mark` (`matters`, `scam warning` or `neither`, or null), `why`,
a `due_date`, whether it was `told` and when it was `judged`; `mail_group` — a label and when it was first
made; `reading_lesson` — the corrected mail's own headers (account, folder, sender name and address,
subject), the mail group, the `change` made, when (`made`), and whether it was `undone`; and `waiting_mail`
— a mail heard but not yet read, by its account, folder, message id, UIDVALIDITY and UID (unique together,
so a mail heard twice is kept once), its date, sender, subject, flags and size, in the order it was heard. A
fifth table, `reader_health`, keeps its own health (below) across a restart.
- **It hears mail under its own account.** It consumes `mesh-mail`'s stream `MAIL` for
`mesh-mail.mail.*.message.added` events alone (`AddedPattern`, `hear.go`), recording each as a row in
`waiting_mail` before the handler returns — so the controller's durable consumer redelivers an event a
store failure lost, rather than it being silently dropped — and keeping a mail heard twice to one row, by
its own unique key above (ADR 0459 §3, §12; `hear.go`).
- **Its batches**: at most 10 mails at a time (`MaxMailsPerBatch`), taken from `waiting_mail` in the order
they were heard; a batch starts no sooner than 160 seconds after the previous batch started (`BatchGap`,
pinned to the constant `MinBatchGap` in every production build; only a build tagged `integration` may
shorten it, and never in a way a unit test lets reach production) — bounding mail reading to at most 225
mails an hour (ADR 0459 §5, §15; `batch.go`, `question.go`). Each batch is one judgement, asked of
`claude-code.claude_code_judge` on this module's own machine (`MESH_NODE`) and collected with
`claude_code_judged` when it answers pending; it is asked to answer `within` at most 40 seconds
(`JudgeWithin`), and the whole question — the framing that a mail is data and never an instruction, the
existing mail groups (largest first, at most 4 KiB), the reading lessons that apply (at most 40, matching
sender or mail group first then newest, at most 12 KiB) and up to 10 mails (each at most 3.5 KiB: headers
at most 512 bytes, text cut to its first 3 KiB) — is bounded to 55 KiB. A mail the provider filed as junk
(its folder named Junk or Spam, or carrying the `\Junk` flag) is said as that fact beside its text. A mail
that has simply gone (moved or deleted since it was heard) is taken out of the waiting queue without ever
being judged; any other read failure leaves the whole batch waiting for the next slot; on success, one
mail entry is written per mail and any new mail group is made; on a refused, timed-out or out-of-shape
judgement, nothing is written, every mail given stays waiting, and the reader's own health (below) is set
unhealthy (`batch.go`'s `RunOneBatch`, `RunBatches`).
- **Its mail signal.** After each batch, win or lose, it tries to tell the operator: every entry marked
`matters` and not yet `told` — carrying one over from an earlier batch that went untold — is gathered into
one `operator-channel.tell` with subject `mail`, naming the mail with the nearest due date first, then the
next one, ending "and N-2 more on the mail page" past two; nothing is sent, and nothing asked at all, when
there is nothing to tell or while the reader is unhealthy (read fresh from the store, not assumed). The
words are checked first, line by line, with `messenger.messenger_check`; any one refused falls back to the
safe form "N mails matter: open the mail page". Only once `tell` answers that some channel actually sent it
are those entries marked `told` (ADR 0461 §1-§6; `tell.go`, `signal.go`).
- **Its health**, `mail_reader_health`, is asked by the node-engine itself (`runtime-health` of kind `tool`,
every 60 seconds) and carries no caller check. It is unhealthy once a batch judgement is refused, times
out, or answers out of shape, naming which of the three, the judgement's own words, and since the first
such failure in a row (a later one in the same run never pushes that time forward); healthy again once a
judgement succeeds. The state survives a restart, kept in its own database rather than only in memory (ADR
0459 §12; `health.go`).
- **What it never does.** It changes no mail: it moves, flags, deletes and sends nothing, asking no verb of
`mail-store` that would (ADR 0459 §11). It keeps no mail body and no text taken from one — only headers, a
summary at most 160 characters, a mark, why, and a due date (ADR 0459 §4).
-2
View File
@@ -25,8 +25,6 @@ Where the two disagree, the implementation wins and the disagreement is stated.
| [`14-a-modules-state.md`](14-a-modules-state.md) | A module's current state on the bus: what the controller creates, the runtime serves, and the first live use showed |
| [`15-the-agent-and-its-licences.md`](15-the-agent-and-its-licences.md) | The operator's agent on every machine and the licence manager that keeps its licences |
| [`16-logins-on-the-bus.md`](16-logins-on-the-bus.md) | The bus's one list of logins, a login per module assignment, persons limited to their tools, sessions calling through the tool runner's login, and no effect on any tool |
| [`17-accounts-on-a-machine.md`](17-accounts-on-a-machine.md) | The operator and agent accounts on the node record, claude-code's own login and guard, the judgement that the agent account cannot become root, and the forge accounts agents push as |
| [`18-the-mail-modules.md`](18-the-mail-modules.md) | mesh-mail's accounts and MAIL stream, the one `grants` list that gives both reading and sending at once, the send path through its own process and the operator's Approve, mesh-mail-web's pages and sign-in, and mesh-mail-notifier's pop-up and its quiet rules |
## What these documents are not
+3 -5
View File
@@ -1,11 +1,11 @@
---
layer: to-be
status: in-progress
status: proposed
code:
- mesh-controller cmd/mesh-builder
- mesh-controller internal/builder
- mesh-controller modules/build-agent
updated: 2026-10-17
- mesh-catalog modules/build-agent
updated: 2026-10-16
decisions:
- "ADR 0405"
- "ADR 0262"
@@ -31,8 +31,6 @@ decisions:
# Building a module
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: mesh-controller's builder (internal/builder, cmd/mesh-builder) implements the recipe kinds described here, and the sections marked built and proven live match the code. Left: not broken down into milestones; issue 1072 found it saying proposed.
[`12-a-module-repository`](12-a-module-repository.md) says what a module may build — an image, an
archive, an upstream mirror — and where the result goes. This says **how a build is modelled**, and
why the current model does not fit what a module is.
+2 -4
View File
@@ -1,13 +1,13 @@
---
layer: to-be
status: in-progress
status: proposed
code:
- mesh-sdk src
- mesh-tools src/broker-nats.ts (and broker-amqp.ts until the rollout)
- mesh-controller internal/link
- mesh-controller cmd/mesh-controller (status)
- mesh-catalog modules/postgres, modules/keycloak (the Go provisioner loop)
updated: 2026-10-17
updated: 2026-10-06
decisions:
- "ADR 0224"
- "ADR 0230"
@@ -24,8 +24,6 @@ decisions:
# The module protocol
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: the wire this design describes is in mesh-sdk and mesh-controller's internal/link, and the design's own checks against the code (2026-09-16) agree. Left: not broken down into milestones; issue 1072 found it saying proposed.
**What a module's code and the mesh say to each other.** An SDK is an implementation of this in one
language and nothing more (ADR 0074).
@@ -1,11 +1,11 @@
---
layer: to-be
status: in-progress
status: proposed
code:
- mesh-host internal/bootstrap
- mesh-host cmd/mesh-bootstrap
- mesh-lab test/integration/one-node-mesh.test.ts
updated: 2026-10-17
updated: 2026-10-06
decisions:
- "ADR 0226"
- "ADR 0067"
@@ -16,8 +16,6 @@ decisions:
# The installation, in full
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: the node-engine's bootstrap (`mesh-host` internal/bootstrap) carries phases 2 and 3 of the installation, so the steps this text calls manual are partly automated. Left: not broken down into milestones; the text is not yet corrected against the code.
Every step from a machine with nothing to a mesh that maintains itself. Written out explicitly
because it is the procedure everything else depends on, and because the parts that do not exist
yet are easier to see beside the parts that do.
+1 -3
View File
@@ -6,7 +6,7 @@ code:
- mesh-controller
- mesh-catalog
- mesh-lab
updated: 2026-10-17
updated: 2026-09-15
decisions:
- "ADR 0067"
- "ADR 0074"
@@ -17,8 +17,6 @@ decisions:
# The work ahead
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Phase 2's blocker (issue 053) is resolved and delivered, and the forge's module declares the package registry in full, Phase 2's done-when. Whether every phase holds is checked under issue 1073, which marks it implemented or names what is left.
Everything decided this cycle and not yet built, in the order its dependencies allow. Each phase
ends at something provable on a running mesh, because a phase that ends at a claim is a phase that
went missing without anything complaining.
+3 -5
View File
@@ -1,8 +1,8 @@
---
layer: to-be
status: in-progress
code: [mesh-controller]
updated: 2026-10-17
status: designed
code: []
updated: 2026-09-25
decisions:
- "ADR 0084"
- "ADR 0027"
@@ -11,8 +11,6 @@ decisions:
# 23 — Choosing a provider
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: mesh-controller's internal/catalogue/resolve.go implements the model described here, and designs 26, 27 and 29 rely on it. Not yet checked against this text clause by clause, nor given an as-is: issue 1074.
A provision is named for what the consumer's code is coupled to
(ADR 0027):
`postgres-database`, not `database`. That decides *what kind* of provider satisfies a requirement.
+8 -62
View File
@@ -1,10 +1,9 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog, mesh-controller, mesh-host, mesh-media-catalog, mesh-vault]
updated: 2026-10-26
status: implemented
code: [mesh-catalog, mesh-controller, mesh-host, mesh-media-catalog]
updated: 2026-10-12
decisions:
- "ADR 0550"
- "ADR 0305"
- "ADR 0158"
- "ADR 0094"
@@ -32,10 +31,9 @@ wrong.
- **A provisioned credential** is the login one module uses against another — a database password,
a broker account. The mesh mints it per consumer↔provider pair and seals it to the machines that
must hold it (ADR 0048),
and its provider makes it true. This design first left it out of the vault, since it already has an owner
and a rotation ([13](13-credentials-and-their-rotation.md)). **It is the vault's now** (ADR 0550 §5, from
ADR 0113): the vault keeps the value and hands it sealed; its provider still makes it true, and its
rotation is the same machinery.
and its provider makes it true. **This is not the vault's**, and the vault does not change it. It
already has an owner and a rotation
([13](13-credentials-and-their-rotation.md)); disturbing it would buy nothing.
- **A module's own secret** is a value a single module needs for itself — the password of a store
it runs privately, an internal signing token. Today the mesh generates this as a value nothing
owns, and so it cannot be rotated. **This is the vault's.**
@@ -47,56 +45,6 @@ The vault, then, is the provider a module turns to for a secret that is **not**
some other provision. It is the answer to *"this module needs a password, and there is no provider
whose job it is to give it one."*
## Every secret lives in the vault
*Decided 2026-10-26 (ADR 0550), on the operator's answers; built in the milestones of ADR 0547.*
**The seat `mesh-vault` holds every secret the mesh keeps.** That is every shared secret, the enrolment tokens
among them; the mesh's signing key; every licence's grants and tokens; every bus account's password or, where
the vault has no value, its hash; and every module's own secrets and credentials. A holder gets a value only
sealed to its own key, on request and reply, or asks the vault to act with it, as for a signature. No secret is
kept in a bucket, a store database or a module's own rows. The private keys a node or the operator makes for
itself stay where they are made (ADR 0550 §1).
- **The signing key.** The vault makes a new signing key for the mesh, with the mesh's authority beside it; the
controller's key is never copied out of its database. The vault signs enrolments, node certificates and the
authority's own on request, through its verb `sign`, for the callers its manifest names, and says `signed`
naming the caller, the kind and the digest, never the key. The public halves are in a state of the vault that
every node reads. The key and the authority leave the vault only as the one copy sealed to the operator key,
opened only in the break-glass recovery below, so losing the vault does not lose the key every signature
depends on. Each node trusts the vault's key beside the controller's, one node first, and the controller's no
more once it loses its lease (ADR 0550 §2).
- **Licence grants and tokens.** The refresh tokens, the API key and the access tokens of every licence are vault
secrets; the licence manager keeps which licence goes to which holder, and nothing secret
([39](39-the-anthropic-licence-manager.md), ADR 0550 §3).
- **Bus account passwords.** A bus account's value is the vault's: until ADR 0547's third milestone the controller
hands the vault the value of each account ADR 0374 §1 covers and keeps the others' values as today (ADR 0374
§4), whose hashes the vault adopts; from then the vault mints every one. The agent of the node holding `mesh-broker` hands the vault,
sealed to it, the bcrypt hash of every user of the list the bus loaded; the vault adopts the hash of each user
it holds no value for (the controller's own, the node and device accounts, the module and process accounts its
values do not cover), writes it under `bus-accounts.<user>` and says `account-hashed` with `adopted: true`. It
never touches a key that holds the hash of its own value, and writes an adopted key again when the loaded list
carries another hash for that user. Its tool `secret_own` lists the adopted users and each adoption, and
`account_adopt` adopts now (ADR 0550 §4).
- **Module secrets** stay as the sections below describe them: a module's own secrets, the credentials between a
consumer and its provider, and the values an operator or a backend delivers. A node composing its own modules
asks the vault for them sealed to its key, and no composition carries one in the clear (ADR 0550 §5).
- **Three exceptions until ADR 0547's third milestone, each ending there:** the controller's own signing key in
its identity database; the bus account values the controller mints, handed to the vault where the accounts are
covered and kept by the controller for the others; and the enrolment tokens and new node accounts the
controller makes and keeps in its store. So **no machine is enrolled before the third milestone** (ADR 0550 §1,
§6).
*How it is checked:* before the third milestone's lease write, the controller's licence and identity databases and
the licence manager hold no secret the vault does not hold, and no exception is left that the vault has not
replaced, read with the vault's `secret_holders` and the store's reader. A unit test that `sign` refuses a caller
its manifest does not name and that no answer or event carries the key; `secret_holders` showing the
operator-sealed copy of the key and no other holder; and live, a node certificate the vault signed that another
node verifies. After the delivery of the hash hand-over, `mesh-grants.grants_user_list` shows no user without a
hash and `secret_own` lists the adopted users; unit tests in the vault (adopt; never touch its own value or a
covered account; rewrite on a new loaded hash) and in the node agent (every loaded user handed, sealed). The
hand-act log shows no enrolment before the third milestone.
## A secret as a provision
A module that needs a secret for its own use requires a `secret` provision, exactly as it requires
@@ -129,10 +77,8 @@ the foundation: installed at genesis, the way the store and broker are raised fi
adopted as modules (ADR 0078). A
mesh has root secrets, so a mesh has a vault; it is not something a mesh opts into
(ADR 0085, amended). It is provisioned the
ordinary way. It does not yet mint the mesh's bus accounts: until the third milestone of ADR 0547 the
controller mints them, hands the vault each covered account's value (ADR 0374 §1) and keeps the others as
today, and from then the vault mints every one
(*Every secret lives in the vault*, above).
ordinary way, and it does **not** mint the mesh's delivery credentials — the controller does that,
because the controller must mint in order to deliver any provision, the vault's own included.
There is **one vault per mesh**, on the control-node, reached from any node the way identity is.
Node scoping (ADR 0084,
@@ -5,7 +5,7 @@ code:
- mesh-controller internal/inventory
- mesh-controller internal/catalogue
- mesh-controller cmd/mesh-controller
updated: 2026-10-17
updated: 2026-10-16
decisions:
- "ADR 0181"
- "ADR 0182"
@@ -16,11 +16,6 @@ decisions:
- "ADR 0117"
- "ADR 0399"
- "ADR 0400"
- "ADR 0412"
- "ADR 0415"
- "ADR 0416"
- "ADR 0417"
- "ADR 0422"
---
# 29 — A node has operator accounts, and the mesh owns what lives under a home
@@ -63,15 +58,6 @@ These are a **family**, not one module: an `ssh-client` module, a shell module,
module, each a *universal-tier* consumer of the account fact — assigned wherever a person logs in,
which is every node, unlike the graphical stack that a capability gates.
**A folder a module owns says how it treats a file it did not write** (ADR 0416, ADR 0422). Declaring a
directory under a home owns it (ADR 0182 §1), and the owner alone writes there and reacts to every file
that enters it, never losing what the file held. The manifest says how, on the directory itself:
`unknown` is `move-out` (the node-engine moves every path the manifest does not declare and `knows` does
not name into `~/.local/state/mesh-moved/<module>/`, with a list), `module` (the module's own process
reacts, as ssh-client does in `~/.ssh`) or `program` (everything in it is the program's, as in a
service's data directory). `move-out` is only for a folder under a home, the home of the account the
assignment runs for (ADR 0412 §5). A folder many modules write in is not declared at all.
## 3. The whole of `~/.ssh` is the mesh's — with one boundary drawn inside it
The predecessor owned a single file (`~/.ssh/config`) and left the rest alone; it drifted, because
@@ -98,9 +84,6 @@ ADR 0117 draws for the uplink and the sshd
module draws for the firewall: **the mesh must never be able to arrange the one failure that severs
its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`.
Since ADR 0415 and ADR 0417 the found class no longer holds in `~/.ssh`: ssh-client moves out what it
does not know into a kept folder, and never moves a key or `authorized_keys`, which keeps this boundary.
## 4. Keys are the mesh's to generate — through a CA, and existing keys are adopted, not replaced
Key *generation* is the mesh's, not each node's improvising its own. The clean form is an **SSH
@@ -1,8 +1,8 @@
---
layer: to-be
status: in-progress
code: [mesh-controller]
updated: 2026-10-17
status: proposed
code: []
updated: 2026-10-06
decisions:
- "ADR 0238"
- "ADR 0221"
@@ -15,8 +15,6 @@ decisions:
# 30 — The mesh updates itself on a push
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: mesh-controller's delivery plans, composition check and merge window (plan.go, release_plan.go, merge_gate.go, merge_window_bus.go, changeplan.go, with their migrations), which design 47 builds on. Not yet checked against this text nor given an as-is: issue 1075.
**Today the mesh does not update itself; a person drives the pipeline by hand, and one class of
change freezes it.** A code change lands in `mesh-controller` or `mesh-catalog`, and getting it onto
the machines is a sequence somebody types. The predecessor's pipelines rebuilt and redeployed on a
@@ -168,7 +166,7 @@ Revision, ADR 0238
it — the module's own directory, the whole repository for a module built from its root, a repository a
recipe names — and a file no build reads, at the root or in a directory holding no module, rebuilds
nothing. One function maps a changed file onto modules and one answers what a merge moves and builds;
the merge handler, the what-if, the composition check and a pull request's check ask them. Only a commit on the
the merge handler, the what-if, the merge gate and a pull request's check ask them. Only a commit on the
module's trunk is registered.
## What a push does not send (2026-10-05)
+8 -15
View File
@@ -1,13 +1,11 @@
---
layer: to-be
status: implemented
code: [mesh-tools, mesh-host, mesh-controller]
updated: 2026-10-17
status: designed
code: [mesh-catalog, mesh-tools, mesh-controller]
updated: 2026-10-03
decisions:
- "ADR 0197"
- "ADR 0195"
- "ADR 0175"
- "ADR 0367"
- "ADR 0160"
- "ADR 0159"
- "ADR 0152"
@@ -165,16 +163,11 @@ matched it by URL.
## What this does not settle
- **The mesh's own verbs on the bus.** Design 33's third family: these now answer directly, through
the `mesh-controller` seat, under ADR 0154 — settled, not open. [As-is 13](../00-as-is/13-the-console.md)
records it live.
- **Narrowing the tool runner's grant per assignment.** ADR 0046 makes it a setting; nothing reads
one yet. Owned by [design 58](58-agent-work-as-the-meshs-own.md) milestone 6 (issue 883): an
identity asked for each assignment is one of that milestone's parts.
- **A person's identity behind the mesh MCP server.** The mesh still sees only the tool runner's own
account, never the caller's; design 15 raised the question and leaves it open. Owned by the same
milestone (design 58 milestone 6, issue 883): its outcome is that every person, agent and module
becomes an identity with a role, and a call carries which one made it.
- Narrowing a mesh MCP server's grant per assignment. ADR 0046 makes it a setting; nothing reads one yet.
- The mesh's own verbs on the bus. Design 33's third family; this document only says where they appear
once they exist.
- A person's identity behind the mesh MCP server. The mesh sees the mesh MCP server's account; design 15 keeps the
question open.
## References
@@ -2,10 +2,8 @@
layer: to-be
status: in-progress
code: [mesh-catalog modules/claude-code]
updated: 2026-10-17
updated: 2026-10-13
decisions:
- "ADR 0411"
- "ADR 0412"
- "ADR 0343"
- "ADR 0274"
- "ADR 0273"
@@ -157,16 +155,6 @@ does the job, or with the rule that a missing tool is created in its owning modu
managed directory, so a session cannot change it, judges with the machines and replaced commands the module
last asked of the controller, and records every refusal and every operator's override in the module's state.
**The wait list and the Stop hook** (ADR 0489, ADR 0490). Beside the guard the plugin carries two more of the
module's own commands as hooks. At every session start the session-start hook prints the session's id and every
open wait on the machine by the name it is kept under, with the call that takes a name back after a /clear (which
gives a session a new id). At every stop the Stop hook reads the session's waits, which `claude_code_wait` keeps
in the module's state under the session's name (who@node) and mirrors into the agent's home, and refuses the stop
while a wait is within its bound and nothing follows it, or past its bound and not yet told to the operator; it
lets the stop through silently when there are no waits, says so on the screen when the list cannot be read, and
refuses the same stop at most three times. Like the guard, both hooks ship in the plugin only: the hook
registration tools refuse a hook at mesh or node scope (issue 340).
## 4. The mesh MCP server
> **Revised 2026-10-03, building it.** The vendor's managed-settings key for tool servers refuses any
@@ -318,27 +306,21 @@ operator's word — and, for the operator's own home, only at the terminal or on
## 9. The account the agent runs as
*Added 2026-10-08 by ADR 0266; changed 2026-10-17 by ADR 0411 and ADR 0412.*
Everywhere above, "the operator's home" and "the operator account" mean **the account the agent runs as**:
the machine account of the identity the module runs for (ADR 0412). claude-code declares that it can run for
an agent identity, in its assignment or when a session is started: started for `nox` it runs with the agent's
home; started for the operator, it is the default run, with the operator's configuration. On a
machine where no agent is marked as the agent account, its assignment is the default run, as every module's is.
*Added 2026-10-08 by ADR 0266.*
Everywhere above, "the operator's home" and "the operator account" mean **the account the agent runs as**.
That is the operator account, unless the node names an **agent account** of its own. The control node does.
**The fact.** The machine accounts come from the identity seat (ADR 0411); the operator no longer states the
agent account at the controller's terminal. A machine's agent account is the machine account of the agent the
identity seat marks as the agent account there, on the operator's Approve: `nox` on the control node. There this module's
assignment is run for that agent, and the controller refuses one that is not. The controller resolves the
machine facts `agent-account`, `agent-home` and `agent-root` from it, and gives the tool runner the account and
its home. Where no agent is marked (every other machine today), the facts name the operator account and `agent-root`
is empty, so nothing changes there, though a login `nox` exists for the modules run for it.
**The fact.** The operator states the agent account at the controller's terminal. No verb and no setting
writes it. The controller resolves the machine facts `agent-account`, `agent-home` and `agent-root`, and gives
the tool runner the account and its home. Where the node names none, the facts name the operator account and
`agent-root` is empty, so nothing changes there.
**What the module declares.**
- No login. The mesh makes the agent's machine account (home, own group, no password) from the identity, and the
node-engine judges on every look that it cannot become root without a person, by the ways ADR 0266 §12 to §17 list.
It detects those ways and no others; it prevents nothing. The `user` resource named by `agent-account`,
which this module declared until ADR 0411, goes.
- A `user` named by `agent-account`, with `root` set from `agent-root`. Where the account is the agent's own,
the mesh creates it (home, own group, no password) and the node-engine judges on every look that it cannot
become root without a person, by the ways ADR 0266 §3 lists. It detects those ways and no others; it
prevents nothing.
- `~/.claude` under that account's home, owned by it.
- `claude-agent`, a command that starts the agent as that account through the operator's own sudo, or
directly when already run as it.
@@ -415,7 +397,6 @@ the helper of §9 where that is an agent account):
| `rules/<name>.md` | one personal rule each |
| `skills/<name>/`, `commands/<name>.md`, `agents/<name>.md` | the operator's own skills, commands and subagents at the operator scope, under their own names; and the home-scope items where the home is an agent account's own |
| `hooks/` | nothing: a hook is the terminal's, in the managed settings. The directory is kept empty |
| `nox-mesh/waits/<session-id>.json`, `nox-mesh/waits/_index.json` | the module's own mirror (ADR 0489, ADR 0490): per Claude Code session id, the open waits of every name bound to that id; and the machine's open names, written whole by `claude_code_wait` and read by the plugin's session-start and Stop hooks; rebuilt from the module's state, never a person's |
| `output-styles/<name>.md`, `keybindings.json` | the operator's output styles and keybindings at the operator scope |
| `settings.json` | the operator's registered settings, preference keys only, with the keys the program itself writes on a person's choice (the model, the spinner, the drafts, the other preferences the vendor's own commands set) taken over into the registration when the program changes them, so a choice made in one session follows the operator. A key that decides what runs (`hooks`, `permissions`, `mcpServers`, `env`, `apiKeyHelper`, `statusLine`, `autoMode`, any key not on the list) is never taken over: changed on disk, it is written back on the look that finds it. `autoMode` is a permission rule, so it lives in the managed settings at the terminal |
@@ -1,10 +1,9 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog modules/claude-licence-manager, mesh-vault]
updated: 2026-10-26
status: implemented
code: [mesh-catalog modules/claude-licence-manager]
updated: 2026-10-04
decisions:
- "ADR 0550"
- "ADR 0209"
- "ADR 0206"
- "ADR 0183"
@@ -19,35 +18,13 @@ decisions:
# 39 — The Anthropic licence manager
**One module knows every Anthropic licence the mesh has, keeps each alive, and decides which consumer gets
which; the vault keeps every grant and token and hands every node's agent its token over the bus**
(ADR 0550 §3, amending the first shape, in which the manager kept the grants and handed the tokens itself).
**One module knows every Anthropic licence the mesh has, keeps each alive, decides which consumer gets
which, and hands every node's agent its token over the bus.**
ADR 0183
decides it; this is the shape. It is the successor of the predecessor's manager module, built from what
that module learned the hard way, and the counterpart of [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md),
which is the consumer on every node.
## Every grant in the vault
*Decided 2026-10-26 (ADR 0550 §3), on the operator's answer that every secret lives in the vault; built in
ADR 0547's second milestone (issue 1126). Until it is delivered, the sections below hold as first written.*
- **The vault keeps every licence's secrets**: the refresh tokens, the API key and the access tokens are vault
secrets ([24](24-the-secrets-vault.md)). The manager keeps its bindings, its licence records without a secret
(account, kind, health, usage, audit), and nothing at rest that is secret.
- **The manager stays the one rotation source.** It alone calls the vendor's token endpoint: to refresh or adopt,
it asks the vault for the grant sealed to itself and hands what it gets back to the vault. A grant offered at
login, sealed to the manager's key (its `public-key` verb is kept), is handed on to the vault the same way.
- **A holder's token comes from the vault.** The agent module on a node registers its public half with the vault,
not the seat, and asks the vault for its token sealed to it. The vault serves only the licence the manager's
binding names for that holder, and says `secret-changed`, never a value. The seat's `current` verb answers which
licence a consumer holds and its health, never the token.
- **It waits for accounts of their own.** The vault hands a value only to a module running under a bus account of
its own (issue 569), so the move waits until the manager and each agent module run under one.
*How it is checked:* a unit test that the vault refuses a token to a holder no binding names or to one without an
account of its own, and a scan after the move that the manager's tables hold no grant.
## 1. What it is
A module, `claude-licence-manager`, holding the mesh-scoped seat **`anthropic-licence-manager`**. One
@@ -66,8 +43,7 @@ Its store holds four things:
**The grants are encrypted with a key the vault made for the manager** — its one `secret` requirement.
The vault keeps that key; the manager keeps the grants. That is ADR 0050's carve-out, one module, one
node, the long-lived grants only. ADR 0550 §3 ends the carve-out: the grants move into the vault, and the
`licences` table keeps no grant (*Every grant in the vault*, above).
node, the long-lived grants only.
## 2. The licences it manages today
@@ -101,9 +77,7 @@ Carried from the predecessor, where each rule was earned by an incident:
## 4. Handing a token to a node
*Amended 2026-10-04 by ADR 0206*, replacing the manager's visits: **what each consumer
should hold is the manager's state, and the token is fetched when it changes.** *Amended 2026-10-26 by
ADR 0550 §3:* the token is fetched from the vault, not the seat; the `bindings` state and its generation stay
the manager's, and the vault serves the licence a binding names.
should hold is the manager's state, and the token is fetched when it changes.**
- **The manager keeps a `bindings` state**, one key per consumer: the licence, its kind, and a
**generation** that increases with every rotation and every switch. Nothing in it is secret.
@@ -188,8 +162,7 @@ gone; a rotation or a switch is a new generation in the `bindings` state.
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
or all), `usage` (current and history), `adopt` (a file on the manager's node, or a key sealed to its
`public-key` — ADR 0209), `public-key`, and `current` (a consumer's token, sealed to the key the
consumer sends — ADR 0206; from ADR 0550 §3, which licence a consumer holds and its health, never the
token, which the vault hands). The manager asks a node for a candidate grant by the agent module's own tool.
consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
## 8. Settings
@@ -206,8 +179,6 @@ one definition serves and one mesh may differ.
| a grant offered with a mismatching identity is refused and one notification emitted | ADR 0183, attribution |
| a failing licence notifies once, and once a day after, not once per tick | §3 |
| the mesh MCP server lists the seat's verbs and `switch` changes a workstation's token end to end | ADR 0132, the exit of the build |
| the vault refuses a token to a holder no binding names, or to one without a bus account of its own | ADR 0550 §3 |
| after the move, the manager's tables hold no grant | ADR 0550 §3 |
## What this does not settle
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-catalog, mesh-controller, mesh-host]
updated: 2026-10-22
updated: 2026-10-14
decisions:
- "ADR 0369"
- "ADR 0303"
@@ -26,9 +26,6 @@ decisions:
- "ADR 0164"
- "ADR 0165"
- "ADR 0166"
- "ADR 0507"
- "ADR 0508"
- "ADR 0510"
---
# 42. The machines' modules, in order
@@ -72,14 +69,14 @@ In order:
| 1 | `sudo` | the operator account's escalation, as a drop-in it owns | declares what three modules' tools assume and nothing stated |
| 2 | `localization` | locale, time zone, mesh MCP server keymap | one machine on another zone and keymap |
| 3 | `time-sync` | timesyncd and its servers | two different daemons across four machines |
| 4 | `pacman` | the package manager's configuration, mirrors and their refresh, cache cleaning; every node's packages on the bus; upgrades one node at a time, each with a package record, a held kernel, an after-upgrade check and a rollback (ADR 0507, ADR 0508) | mirrors generated once and never again; caches never cleaned; no view of which version runs where; every node upgraded at the same moment with no way back |
| 4 | `pacman` | the package manager's configuration, mirrors and their refresh, cache cleaning | mirrors generated once and never again; caches never cleaned |
| 5 | `logrotate` | the timer and base configuration | rotation running on one machine of four |
| 6 | `avahi` | the daemon | on all four, owned by none |
| 7 | `systemd` | the service manager's tools (to-be 41 WP4) | built, assigned nowhere |
| 8 | `docker` | the runtime's packages, base configuration, group | four configurations, one owner on one machine |
| 9 | `ssh-client` | everything under `~/.ssh` (research 027/03) | a predecessor's entries winning over the mesh's; stale keys |
| 10 | scripts | the operator's own scripts, shared and per role (research 027/03) | under no version control, copied by hand |
| 11 | `kernel` | kernel, microcode, boot entries, the fallback kernel and its boot entry, and the kernel's run-time settings per machine (transparent huge pages first) | two machines without microcode; huge pages left at the distribution's `always` on a machine whose slow disks they flood (issue 746); only one machine with a fallback kernel |
| 11 | `kernel` | kernel, microcode, boot entries, and the kernel's run-time settings per machine (transparent huge pages first) | two machines without microcode; huge pages left at the distribution's `always` on a machine whose slow disks they flood (issue 746) |
`systemd`, `pacman` and `docker` hold the three seats that apply resources
(ADR 0207):
@@ -104,33 +101,6 @@ mode in force and the file pages each unit holds in huge pages. `docker` holds t
ADR 0164 decides and the capability detected as
ADR 0165 decides; what of the three is not built yet is tracked by issue 398.
**Changed 2026-10-22** (ADR 0507, ADR 0508, ADR 0510; milestone issues 1840 and 1841). `pacman` gains a
process of its own on every node, the keeper, under an account of its own and a bus account of its own. It keeps
the node's installed packages and what an upgrade would change in the module's states, written only on change
(at its start, when the package manager's lock is released, when the mirror list is rewritten, and once a day),
and says each change as an event; the controller keeps the packages each node's modules declare, so the view
answers every node, one that is off included, and names the packages no module declares. Upgrades are runs on
the bus. The weekly timer of ADR 0477 §5, which upgraded every node at the same moment, is dropped. Instead the
nodes upgrade one at a time, in the order of the mesh-level key `upgrade-order`, each after the node before it
passed its after-upgrade check. With no order set no round starts, and every node's health says why. Before each
run a package record is taken and read back. A kernel that an installed DKMS module's own bound excludes is held
with its headers, and said. After each run the after-upgrade check names left configuration files, the
distribution's news, newly failed units, a restart needed, processes still mapping a replaced file and DKMS
modules not built for an installed kernel. A rollback returns a node to a record and says first what it would
change. Only the operator passes a node that stopped a round. The keeper never runs as root: the few acts that
need root go through a root-owned helper process with a closed list of verbs, on a socket only the keeper's
account opens (ADR 0510). The module's health is read from the bus, not from a unit's age.
`kernel` gains the fallback kernel: `linux-lts` and its headers on every machine, with its boot entry on
whichever loader the machine boots with, read from the machine (systemd-boot or GRUB), the default entry left
untouched (ADR 0508 §9-§10, changing ADR 0381 §4). A hook found on a machine that copies every kernel image to
one fixed boot name is taken over: moved into the module's state and named, since it would put the fallback
kernel in the default entry's place. *How it is checked:* the modules' unit tests (the root helper refuses every
verb and argument off its list and every account but the keeper's; the round starts a node only after the one
before it passed; the boot-entry script in stand-in trees of both loaders), an integration test that runs the
keepers of two made-up nodes on a real bus, and, after the delivery, the view answering every node and every
boot menu listing the fallback kernel.
**Added 2026-10-04.** Two more modules for every machine:
- `power` holds `node-power` (ADR 0211). Other modules contribute code for
its moments (after boot, before sleep, after waking, before shutdown, on mains, on battery), and it
@@ -69,7 +69,7 @@ calls, the hand-act log, the facts snapshot), **`mesh-host`** (the node-engine:
order, epoch refusal, the `report` verb, rollback witnessing), **mesh-tools** (the tool runner and the
mesh MCP server: their heartbeat, passing every argument, health answers), **mesh-catalog** (the
`operator-channel` seat's holder and channels, the watcher, the providers' retirement, the catalogue's
composition check), **mesh-sdk** (the TypeScript providers' loop and its retirement), **mesh-lab** (the
merge gate), **mesh-sdk** (the TypeScript providers' loop and its retirement), **mesh-lab** (the
replays and the induced-failure scenarios).
---
@@ -320,7 +320,7 @@ producer that wrote it.
| Probe | Asserts | From |
|---|---|---|
| D1 | every machine's declaration composes, and passes the node-engine's validation (the validator is a package of `mesh-host` the controller and the composition check import — one validator) | 236, 263 |
| D1 | every machine's declaration composes, and passes the node-engine's validation (the validator is a package of `mesh-host` the controller and the merge gate import — one validator) | 236, 263 |
| D2 | every holder of the mesh's resolver answers a machine name for IPv4, and NODATA for IPv6 | 262 |
| D3 | every seat on record has a live holder that answers (`holder-silent`, H3) | 208, 218 |
| D4 | every kept archive is held by a manifest | 253 |
@@ -685,7 +685,7 @@ the bus server, the store and the node-engine. No secret and no address: an addr
from a documentation range. It is kept in the artifact store as `facts/latest`, where the build seat
reads it.
**The composition check.** In mesh-controller, `mesh-host` and mesh-catalog, a check composes every machine of
**The merge gate.** In mesh-controller, `mesh-host` and mesh-catalog, a check composes every machine of
the snapshot with the change applied and runs the node-engine's validator over each. A change that
makes any machine fail to compose or validate fails its check, naming the machine's role and the
module. The resolver module's tests run under both C libraries the snapshot lists. A test in each core
@@ -907,7 +907,7 @@ a build whose migration cannot be undone; the verdicts of the next three core bu
| Repository | Delivers |
|---|---|
| mesh-controller | the facts snapshot and S14 |
| mesh-controller, `mesh-host`, mesh-catalog | the compose-and-validate check; versions tested as run; the resolver's tests under both C libraries |
| mesh-controller, `mesh-host`, mesh-catalog | the compose-and-validate merge gate; versions tested as run; the resolver's tests under both C libraries |
| mesh-lab | R4, R5 and the rest of the window's incidents; running the replays on every core merge |
**Done when:** the replays of 236, 262, 263 and 266 fail on the commit before their fix and pass after;
@@ -921,7 +921,7 @@ the verdict as their status, and `merge-check.sh`; in `mesh-host` `merge-check.s
installer's user list; in mesh-lab the replays of 262 and 266, the register and the prover; in hq the
replay rule in `cycle.py`. The prover ran the replays of **236, 262, 263, 266 and 273: each fails on the
commit before its fix** (236's check did not exist there) **and passes on it**. **Not yet:** R4, R5 and the
rest of the window's incidents as replays; the composition check's first runs against the live mesh's snapshot;
rest of the window's incidents as replays; the merge gate's first runs against the live mesh's snapshot;
the status required to merge, which is the operator's setting on the forge; mesh-tools' and mesh-sdk's
`merge-check.sh`.
File diff suppressed because it is too large Load Diff
@@ -448,7 +448,7 @@ open:
composes the field — the endpoint as the port the machine published it on, the defaults written out — only
for a machine whose newest statement says so, and strips it for an older engine, which parses strictly
and would refuse the whole declaration. So the engine rolls out first, then the controller, and the
catalogue declares only once that controller runs: until then the composition check refuses a declaration, which
catalogue declares only once that controller runs: until then the merge gate refuses a declaration, which
is the protection working.
- **The engine's looks.** http and tcp from the machine to the published port; a redirect is an answer and
is not followed; a certificate is not judged. A unit's readiness is read from the show liveness already
+9 -19
View File
@@ -1,24 +1,21 @@
---
layer: to-be
status: in-progress
code: [hq 00-META/checks/words_test.go, mesh-catalog merge-check.sh]
updated: 2026-10-20
code: [hq 00-META/checks/words.py, mesh-catalog merge-check.sh]
updated: 2026-10-11
decisions:
- "ADR 0429"
- "ADR 0244"
- "ADR 0006"
- "ADR 0008"
- "ADR 0302"
- "ADR 0354"
- "ADR 0458"
---
# 49 — The mesh in domains
**Every concept of the mesh belongs to one of thirteen domains, which owns its one word. The glossary is
**Every concept of the mesh belongs to one of eleven domains, which owns its one word. The glossary is
organised by them and is the authority; a word it retires is named in the entry that replaced it, with
where it is retired; and two checks hold the documents and the tools' descriptions to it.**
(ADR 0244; the eleventh, Media, ADR 0302; the twelfth, Config, ADR 0354; the thirteenth, Mail, ADR 0458.)
(ADR 0244; the eleventh, Media, ADR 0302.)
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. The glossary
@@ -34,14 +31,12 @@ shape — what each is for, where its records live, and how they depend on one a
| **Placement** | to decide, send and apply what each node runs, and say why | the controller's `inventory` database |
| **Provisioning** | to resolve which module serves a provision for which consumer, and wire the two | the controller |
| **Identity and access** | to issue, hold, rotate and check credentials and grants | the controller's `identity` and `licences` databases, and the vault |
| **Delivery** | to take one commit from its pull request to every node that should run it, and put it back when it fails | the `mesh-delivery` module and the controller's planner and rollout today; from each release of ADR 0429 on, the parts of the repository `mesh-delivery` it builds |
| **Change and delivery** | to take one commit from its pull request to every node that should run it, and put it back when it fails | the `mesh-delivery` module, and the controller's planner and rollout |
| **Health and repair** | to notice what is wrong, say it once, repair what may be repaired unattended, and record what was done by hand | the controller's condition store |
| **Data** | to know every item of data a module holds, how precious it is, and keep it recoverable | the manifests' data declarations, and every node's `node-backup` seat |
| **Connectivity** | to make every node and module reachable by name where it should be, and unreachable where it should not | the controller |
| **Operator and conversation** | to let the mesh and its operator tell, ask and answer, and act only on an answer it can trust | the router, and the controller for authorising asks |
| **Media** | to find, fetch and file copies of the works a person watches, listens to and reads, each in the library its kind names | the `media-importer` module's settings and memory, and the servarr apps' own libraries |
| **Config** | to hold, check and resolve every value a module reads that someone may change, and say where each resolved value came from | the `mesh-config` module, in its buckets on the bus, with its store as the history behind them |
| **Mail** | to hold the operator's mail accounts, say each mail on the bus, note each mail for the operator and tell them of the mail that matters, and send a mail only on the operator's Approve | `mesh-mail` (its index database, attachment bucket and account buckets) and `mesh-mail-reader` (its database of mail entries, mail groups and reading lessons) |
Beside them, **the record** is this repository's own vocabulary — research, decisions, designs,
issues, playbooks, checks — listed because its words meet the mesh's.
@@ -66,7 +61,7 @@ a change of meaning upstream ripples down, never up. Arrows point downstream.
+-------------> Health and repair <-------------------+
| |
v v
Delivery Operator and conversation
Change and delivery Operator and conversation
| ^
+-------------------+
(a held delivery waits for a person's word)
@@ -75,12 +70,7 @@ a change of meaning upstream ripples down, never up. Arrows point downstream.
Media stands beside the diagram, below Module alone: its words are about what a person watches, not
about the mesh's machinery, and no other domain reads them.
Mail stands below the diagram's foot: it is downstream of Identity and access (the operator grants each mail
account to a caller) and of Operator and conversation (a mail send is an ask, a mail signal is a notice through
the operator channel), and upstream of nothing. It is the operator's own correspondence, which the mesh holds and
reads for them, not the mesh talking about itself, so it is not part of Operator and conversation (ADR 0458).
Delivery sits low because it reads health and data; in time it runs first, and asks Placement
Change and delivery sits low because it reads health and data; in time it runs first, and asks Placement
to send. An authorising answer acts in whichever domain asked, through that domain's own verb: the
conversation owns the asking, never the act.
@@ -94,7 +84,7 @@ ADR 0006 named seven contexts of the controller. Each is a domain now, under the
| config | split: settings to Module and Placement, secrets to Identity and access |
| connectivity | Connectivity |
| provisioning | Provisioning |
| delivery | Delivery — owned by the `mesh-delivery` module, not by the controller (ADR 0239); named so by ADR 0429 §2, its parts living in the repository `mesh-delivery` |
| delivery | Change and delivery — owned by the `mesh-delivery` module, not by the controller (ADR 0239) |
| observability | Health and repair: the mesh built conditions, not "alerts", and half of the domain is repair |
| identity | Identity and access, with the licences store the seven did not name |
@@ -127,7 +117,7 @@ rule stands with the new word: where a domain's records live in the controller,
## How it is checked
- **In this repository**, the hq check (the words test, `go test ./...`) runs on every pull request as part of the repository
- **In this repository**, the hq check `words.py` runs on every pull request as part of the repository
check. It reads the glossary's *Not:* and *Identifier* lines and fails on a retired word or a bare
identifier in running prose — quotations, code and link targets excepted — in `00-META/`, both layers of
the designs, `AGENTS.md`, `README.md`, and research and issues dated from the decision onward. It fails
+4 -35
View File
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-catalog modules/mesh-issues, mesh-catalog modules/claude-code, mesh-catalog modules/messenger, mesh-sdk]
updated: 2026-10-26
updated: 2026-10-11
decisions:
- "ADR 0275"
- "ADR 0278"
@@ -13,7 +13,6 @@ decisions:
- "ADR 0291"
- "ADR 0294"
- "ADR 0300"
- "ADR 0493"
---
# 53 — The issue tracker
@@ -82,10 +81,7 @@ agent finds them with `mesh_search` ("open an issue") and calls them as `issue-t
| `set` | a number and any of `severity`, `needs`, `suggested-owner` | changes them as a person decides (§ 10) |
| `tracking` | optionally condition keys | for every condition key an issue links, the issues that link it and where each stands: status, needs, worker, fix (§ 12) |
| `groom` | optionally `confirm` and a pass's id | the duplicate check over the backlog (§ 9): without `confirm`, starts a dry run in the background and answers its id; with the id alone, its progress and findings; with `confirm` and the id, applies exactly that pass's findings, each checked again |
| `import` (slice 3) | optionally `dry-run`, a commit, and `milestones` with a design's number | reads every issue folder of hq through the records module and writes each as an issue at its own number; creates what is absent, fills a status the tracker holds empty, never overwrites, never advances the counter; run again, changes nothing and says so. With `milestones`, it seeds that design's milestone records from its table instead (§ 13) |
| `milestones` | optionally a design, a status, or one milestone as design/id | the milestone records, each with its issues and their count by status; one milestone with its issues' rows and its history (§ 13) |
| `milestone-set` | a design, an id, an outcome, optionally an order, or `announce` | makes a milestone record, or changes its outcome or order; with `announce`, says the record again as its event (§ 13) |
| `milestone-move` | a milestone as design/id, a status and a reason | opens or closes a milestone; closing is refused while an issue linked to it is neither resolved nor wontfix (§ 13) |
| `import` (slice 3) | optionally `dry-run`, a commit | reads every issue folder of hq through the records module and writes each as an issue at its own number; creates what is absent, fills a status the tracker holds empty, never overwrites, never advances the counter; run again, changes nothing and says so |
Each verb's description says in plain words what it does and what it refuses, without the mesh's internal names, so
an agent picks the right one from `mesh_search` alone. A refusal says what was wrong and what would be accepted.
@@ -104,8 +100,8 @@ the issue's worker, who works on it, as the worker states it
- `located-in`: entries of the registered list of owners (repositories and modules), never free text;
- `fixed-by` (pull requests or commits), `amended-design`, `replay` or `replay-none`, and a reason for `wontfix`;
- `notes`: appended, each with who wrote it, when, and the text;
- `links`, typed: to another issue (`duplicates`, `relates-to`), a pull request, a commit, a decision record, a design,
a condition key (the issue **tracks** that condition, § 12), a delivery, or the one milestone it belongs to (§ 13);
- `links`, typed: to another issue (`duplicates`, `relates-to`), a pull request, a decision record, a design, a
condition key (the issue **tracks** that condition, § 12) or a delivery;
- `documents`: the report, and a diagnosis and a resolution when written, each with its author and date, revised in
place with its history kept (ADR 0291);
- `record`: the path of the issue's folder in hq, while one exists; none is opened any more, and the folders are
@@ -207,7 +203,6 @@ worker; one that stops moves the issue on, or is stale a day later.
| The triage fills only what is empty, links no cycle, and an issue is ready only when its dependencies' fixes are delivered | the rows of ADR 0279's "How it is checked" |
| The duplicate check acts only on a clear duplicate, never on an older or a resolved issue, once per issue, loudly when it cannot judge | the rows of ADR 0278's "How it is checked" |
| An issue's documents are kept whole, with author, date and history, and the import of hq's folders loses nothing (ADR 0291) | the module's tests of `documents` and of the import, one case per document kind; the comparison of every issue with its folder before the folders are removed, written on issue 427. Tracked by issue 427 |
| An issue belongs to at most one milestone, linked only by the verb, and only to an open one (ADR 0493 §2 to §4) | the module's table test of the `milestone` and `commit` link kinds, one case per refusal; the test that the triage and the duplicate check never add a `milestone`, `pull-request` or `commit` link; one integration test that starts the module whole on a real bus and makes, links and refuses |
| Live | on the mesh, an agent calls `issue-tracker.open` and gets the next number; two sessions on two nodes open at the same moment and get two different numbers; `list` shows both |
## 8. The slices after the first
@@ -317,32 +312,6 @@ with no pull request open is **abandoned**.
**tracking line** in every condition's message; the words and the verdict it changes are its own (to-be 45 § 5).
The controller learns nothing of issues.
## 13. Milestones
**A milestone is a record of the tracker, made from its design's table** (ADR 0493 §1). The state `milestones`, which the
manifest declares beside `issues`, keeps one value per milestone, keyed by its design's number and its id: the id as the
design's table writes it, the outcome, its order in the table, its status (`open` or `closed`, with a reason), who made
and who last moved it and when, and the hq commit and path it was seeded from. It is written by compare-and-set at the
revision read, and its history is the key's kept values with the events `milestone-set` and `milestone-moved`.
**An issue's milestone is the issue's own link, not a list on the milestone.** The link kind `milestone` (its target
design/id, such as 47/R6) is written in the issue's log like any link, so the rule that an issue belongs to at most one
milestone (ADR 0493 §2) is checked inside that issue's one write, refused naming the milestone it is in; a link to a
milestone that does not exist or is closed is refused (§3: a fix found on the way joins the open milestone). A milestone's
issues are read from the issues in memory. Pull requests and commits are linked to the issue they fix the same way, with
the kinds `pull-request` and `commit` (§4); nothing reads them from a description, and the triage never adds one.
**The seed** is `import` with `milestones` and a design's number: it reads the table through the records module, makes
each record that is absent in the table's order, links each issue it names that has no milestone, refuses (and names)
an issue another milestone holds or two rows name, closes a milestone whose issues are all done (wontfix, or resolved
with every live fix delivered, or resolved with no live fix) when it refused none of its row's issues, and run again
changes nothing. Once a design is seeded, the tracker is the only list of its milestones'
issues: the design's table keeps its rows without an Issues column, and the knowledge base's `progress` reads
`milestones` (issue 1781).
Readiness, the one ask per milestone and its release are the delivery seat's (ADR 0493 §7 to §11, issue 1783); the
tracker gives them the records and links they read.
## What it leaves out
Priorities beyond severity, notifications beyond the `ready` and `ready-to-verify` events and the stuck-fix warning, full-text or semantic search, the forge's own issue lists, and an index of any kind:
@@ -1,8 +1,8 @@
---
layer: to-be
status: in-progress
code: [mesh-controller]
updated: 2026-10-17
status: designed
code: []
updated: 2026-10-10
decisions:
- "ADR 0289"
- "ADR 0201"
@@ -12,8 +12,6 @@ decisions:
# 54 — The controller's records on the bus
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: mesh-controller's record mirrors (internal/recordmirror, cmd/mesh-controller/record_mirrors.go, internal/inventory's record mirrors), the mechanism specified here. Not yet checked mirror by mirror; not broken down into milestones.
**What each node runs, how far each rollout has got, what each build is doing and what each setting holds
can be watched on the bus, and what is watched never says something the controller's store does not**
(ADR 0289,
+10 -118
View File
@@ -1,19 +1,15 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog, hq]
updated: 2026-10-18
status: designed
code: []
updated: 2026-10-12
decisions:
- "ADR 0290"
- "ADR 0321"
- "ADR 0423"
- "ADR 0424"
---
# 55 — The knowledge base
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built and live since 2026-10-11: the knowledge base is the module mesh-knowledge in mesh-catalog, holding every decision record and research effort (issues 425 and 426, resolved and delivered). Left: the later slices, issues 428 and 429.
**The mesh keeps its decision records and research efforts whole in a module of its own, `mesh-knowledge`, one
instance on the control-node, holding the seat `knowledge-base`: each record's number, status, topic, typed links,
its clauses with their own status, and its reasoning, with every version kept. A record is proposed, checked when it
@@ -30,8 +26,7 @@ gets, in one call under 4 KiB, the clauses in force, the records they come from,
read the reasoning behind each.
This document designs the first slice in detail, tracked by issue 425. The slices after it are in
[§ 9](#9-the-slices-after-the-first), which also designs the third: designs and the glossary's entries become records,
and the knowledge base works out each design's state (ADR 0423, ADR 0424).
[§ 9](#9-the-slices-after-the-first).
## 1. The parts
@@ -44,12 +39,9 @@ and the knowledge base works out each design's state (ADR 0423, ADR 0424).
├ verbs ask, get, holds, links, history, reserve, propose, import, return
│ served by the control-node's tool runner
├ events proposed, reviewed, accepted, changed, imported, drafted, confirmed, returned
├ consumes the router's warrants: the operator's Approve for a record that needs it (§ 5)
└ consumes the delivery module's delivery-done announcement: a design's state (§ 9)
└ consumes the router's warrants: the operator's Approve for a record that needs it (§ 5)
mesh-issues (control-node) the counters for decision and research numbers (§ 6);
the issues' design links, read for a design's state (§ 9)
mesh-delivery (its seat) announces each delivery done, on the bus (§ 9)
mesh-issues (control-node) the counters for decision and research numbers (§ 6)
claude-code (same node) claude_code_judge: review a proposal (§ 5), draft and confirm clauses (§ 7)
records (any node) records_read at a commit: the import's source (§ 8)
messenger (control-node) the router: the operator's ask and its warrant
@@ -63,11 +55,9 @@ the records module, as the issue tracker's import does.
## 2. What one record holds
- `number` and `kind` (decision or research; with the third slice also design and glossary entry, § 9), its key
together; `title`, `topic`, `status`, `date`, `deciders`;
- `number` and `kind` (decision or research), its key together; `title`, `topic`, `status`, `date`, `deciders`;
- its **reasoning**, in sections: for a decision, context, the options weighed, consequences, how it is checked,
references; for a research effort, its overview and each of its documents by name; for a design, its `to-be`
section and, where something of it runs, its `as-is` section (§ 9); markdown, as written;
references; for a research effort, its overview and each of its documents by name; markdown, as written;
- for a decision, its **clauses** (§ 3);
- its **links**, typed, each stored once and read from both ends;
- its **versions**: every change is a new version with who made it, when, and why in one line; `history` lists them
@@ -200,89 +190,11 @@ field by field and section by section, and the result is written on issue 426.
|---|---|---|---|
| 1 | this document: the module, the import, clauses, answers, `propose`, its own review and acceptance | the acceptance sentence above, live, and a record proposed, reviewed and accepted without a pull request | issue 425 |
| 2 | the decision records and research efforts leave this repository; path mentions become "ADR NNNN" and "research NNN"; playbooks 01 and 02 changed | this repository holds no decision record, and `get` answers every one | issue 426 |
| 3 | designs and the glossary's entries as records, moved in one delivery, and each design's state worked out by the knowledge base (§ 9.1 to § 9.3; ADR 0423, ADR 0424) | `get` answers design 47 with its sections and links, a word's entry answers from the knowledge base, and a design's state follows its issues' deliveries without anyone writing it | issue 428 |
| 3 | designs, playbooks and glossary entries as kinds | `holds` answers for a design's decisions and a glossary word | issue 428 |
| 4 | meeting notes, drawings and blog posts | not decided; needs its own decision | issue 429 |
Numbers from the issue tracker, which `reserve` needs, are the tracker's own slice, tracked by issue 424. This
repository keeps no copy of the records, generated or otherwise (issue 435, answered by the operator). Playbooks
stay in this repository as files: they are how work is done here, not records of what was decided (research 061).
### 9.1 Designs and glossary entries as records
**A design** is a record of the kind `design`, named "design NN" and keeping its number (ADR 0423). Its text is its
sections: the `to-be` section, what is being built, and the `as-is` section, what runs. Each of the as-is documents
becomes the as-is section of the design it describes, never a record of its own: the import matches 3 of the 18 by
name and 15 by topic, and shows the operator the list before it runs. Its owning repositories are its field `code`;
its decisions, and every design it names, are `cites` links. It holds no state written by anyone (§ 9.2). A new
design takes its number from `reserve` and is written with `propose`, and is reviewed as every record is (§ 5).
**A glossary entry** is a record of the kind `glossary entry`, one per entry of the glossary page, kept whole: its
head word or words (an entry may name several with one meaning, as research effort, graduation, hand-off and playbook
do), its domain, its meaning, its *Not:* line with each retired word's scope, and its identifier line. Each row of
the page's homonym table becomes an entry of its own, naming the qualified form each domain uses. The entries replace
the page as the authority on words (ADR 0423, changing ADR 0244).
**The readers change with them.** Everything that read the page or the design folders reads the records instead:
| Reader | Reads, after the move |
|---|---|
| the knowledge base's own rule on a retired word (§ 4) | the *Not:* and identifier lines of the entries, no longer the page through the records module; it judges a design when it is proposed, as it judges a decision |
| hq's word check | the entries' *Not:* and identifier lines, and its uniqueness check (a head word heading two entries, or also retired) over the entries; it covers what stays in this repository, and designs leave its coverage |
| the catalogue's repository check | the retired words of no scope or *(tools)* from the entries; its copied list is retired, so hq has no copy to compare |
| hq's cycle check | the design records, for every design naming its decisions, not files under `03-DESIGN/` |
Each of the four fails, saying so, when the knowledge base does not answer, and never passes on nothing.
**Links become links between records, and the old paths go.** At the import every file link inside a design becomes
a link between records, which the rule on a link to nothing then checks (§ 4). The links from documents that stay in
this repository are rewritten to name the design ("design 47"). No page stays at an old path: afterwards this
repository holds no file under `03-DESIGN/` and no `00-META/glossary.md`.
**It moves in one delivery**, as the records did: the import, the two kinds, the four readers, the rewritten links,
the playbooks' steps that write a design, and the instruction text sessions read, which names the glossary's place.
A reader left behind would pass on nothing, so none moves alone.
### 9.2 A design's state
**The knowledge base says a design's state, and nobody writes it** (ADR 0424): proposed, designed, in progress or
implemented, kept matching what runs. No person, session or document sets it, and `propose` refuses a design that
carries one.
It reads two things. The issues are those the issue tracker holds with a `design` link to the design. Whether one
is done comes from its deliveries: an issue is done when it is resolved and the delivery its fix went out in reached
`delivered`, whether a hotfix alone or a delivery group. The delivery module announces each delivery done on the
bus, and on each announcement the knowledge base works the state of every design that issue links out again.
```
issue tracker ── issues with a design link ──┐
├──▶ knowledge base ── works out ──▶ the design's state
mesh-delivery ── "delivery done", bus ───────┘ (served by get, ask)
```
**The rules.**
| Case | State |
|---|---|
| a decision the design names is not accepted | proposed |
| no linked issue done, or no linked issue at all | designed |
| some linked issues done | in progress |
| every linked issue done | implemented |
Three kinds of issue count apart: an issue closed won't fix is left out; an issue resolved with nothing to deliver,
a fix of documents only, is done when resolved; and a new issue linked to an implemented design makes it in progress
again, until that issue is done. Milestones are not read: a milestone is a group of a design's issues delivered
together, and the state depends only on the issues and their deliveries.
**Imported designs say what they lack.** A design imported with no linked issue shows `no issues linked yet` beside
designed, until an issue is linked to it; the import names each such design by number. Linking their issues is
tracked by issue 428.
### 9.3 Its place beside design 47
The delivery-done announcement this slice reads is design 47's
([47 — Delivery](47-delivery-from-commit-to-delivered.md)): its hotfixes and delivery groups, and each delivery's
end at `delivered`. This slice is an extension after design 47's last milestone. It changes none of design 47's
milestones and nothing of how a delivery runs; it only listens for the announcement.
repository keeps no copy of the records, generated or otherwise (issue 435, answered by the operator).
## 10. How it is checked
@@ -300,26 +212,6 @@ milestones and nothing of how a delivery runs; it only listens for the announcem
| A record written in this repository meanwhile writes its clauses | `records.py`, check `clauses`, on every hq pull request, until the records left this repository (issue 426); since then `records.py`, check `no-records`, refuses any record written here |
| Live | the acceptance sentence, asked from a session outside this repository |
**The third slice's acceptance tests** (§ 9.1 to § 9.3; research 062's direction), what its build proves before it
is done:
1. After the import, `knowledge-base.get` of design 47 returns its sections and its links, and every link from a
design resolves to a record: none is refused by the rule on a link to nothing.
2. This repository's main holds no file under `03-DESIGN/` and no `00-META/glossary.md`, and its link check passes
with the 76 links from documents that stay here rewritten.
3. hq's cycle check and word check read the knowledge base, and fail, saying so, when it does not answer.
4. A proposal holding a retired word is refused after the move, the word read from the entries; the entries are
175 and hold 183 head words and 29 retired words, 0 unparsed, both counts checked.
5. In the module's tests, each state rule of § 9.2 gives its state from fixture issues; live, an issue linked to a
design whose hotfix is delivered turns the design implemented within a minute of the delivery being done, and
a new issue linked to it turns it in progress.
6. The import reports every design with no linked issue as `no issues linked yet`, by number.
7. The instruction text sessions read names the glossary's new place.
8. Every entry of the glossary's Config section is filed under the Config domain, and the domains the entries name
are the twelve of ADR 0354.
9. Tests 1 to 8 pass together after the one delivery, run against main and the live knowledge base, with nothing
in this repository, the catalogue or the instruction text pointing at a removed path.
## What it leaves out
Semantic or vector search; a browser view; line comments on a proposal (the judgement's findings name the section and
@@ -1,8 +1,8 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog, mesh-controller]
updated: 2026-10-17
status: designed
code: []
updated: 2026-10-13
decisions:
- "ADR 0316"
- "ADR 0317"
@@ -12,8 +12,6 @@ decisions:
# 56 — The mail client, set up by the mesh
**Where it stands, 2026-10-17** (the records cleanup, issue 1072; playbook 07, "The design's state is written back"). Built on main: issue 586, this design's core, is resolved and delivered (hq#336, mesh-catalog#262, mesh-controller#286), and mesh-catalog's betterbird module has every piece named here. Not yet proven live end to end, nor given an as-is: issue 1076.
**On every node that holds a display server, the `betterbird` module installs Betterbird from a pinned
upstream release, ships the Owl add-on with it through Betterbird's enterprise policy, and declares the
operator's mail accounts without their credentials. A password or token never passes through the mesh: each
@@ -1,8 +1,8 @@
---
layer: to-be
status: in-progress
status: designed
code: [mesh-controller, mesh-host, mesh-sdk, mesh-catalog]
updated: 2026-10-17
updated: 2026-10-14
decisions:
- "ADR 0348"
- "ADR 0349"
@@ -61,10 +61,6 @@ both, by the form of its manifest, and the old forms stay accepted until the las
Inside the release this still holds: the media catalogue and the applications kept in their own
repositories move after it, so the old forms stay accepted until they have (R1).
**Where it stands, 2026-10-17** (the Milestones section below). The packages' branches were merged into
main of mesh-controller, the node-engine (`mesh-host`) and mesh-catalog on 2026-10-16. T6 moved after them, as
planned above, and is resolved. B3 was left out as well, so B3, R1 and R2 are what is left.
## What exists already, measured
Counted 2026-10-14 at mesh-controller `1601cb1`, the node-engine (`mesh-host`) `1a26778`, mesh-sdk `a2d2af0`, mesh-catalog
@@ -151,15 +147,15 @@ glossary says it is the older word. **Proof:** `words.py` passes.
stage.* A file resource takes `"template": "files/<name>"` in place of `content`; the module's build packs
`files/` into an archive artifact of its own (the existing `archive` packing); registration and `Resolve`
read each template's text back from the packed tree into `content`, so composition and the node-engine
are unchanged; `module check` and the composition check read templates from the repository tree beside the
are unchanged; `module check` and the merge gate read templates from the repository tree beside the
manifest; a file naming both `template` and `content` is refused. **Proof:** composition tests: a module
with its text in `files/` composes byte-identically to the same module with it inline; the composition check
with its text in `files/` composes byte-identically to the same module with it inline; the merge gate
judges a change to a template alone.
**T2 (issue 655) — The twelve modules with copies.** *mesh-catalog. A day.* adwaita, clipmenu, dunst, feh,
gnome-keyring, i3, i3status-rust, lemurs, picom, rofi, screen-lock and xdg move to their templates; the
34 inline copies and the `sameAsSource` tests go. **Proof:** each module's composed declaration on its
nodes is unchanged (the composition check's plan says no file changes).
nodes is unchanged (the merge gate's plan says no file changes).
**T3 (issue 656) — The desktop modules.** *mesh-catalog. A day.* Every remaining module assigned only to
workstations. **Proof:** as T2.
@@ -337,17 +333,6 @@ removed code.
amended, the 67 documents that still say "setting" in running prose are reworded so the glossary can
retire the word (F4 left it off the *Not:* line for them), design 49 lists the Config domain, ADR 0348's and 0349's built status is said, and this document's status becomes `implemented`.
## Milestones
The packages above were built as one delivery, not milestone by milestone; the milestones group them by what
each makes true. Whether a milestone's issues are delivered is read from the issue tracker (playbook 07).
| # | Outcome | Packages | Issues |
|---|---|---|---|
| 1 | Config, provisioning, secrets, bindings, collections and runtime asks run the new way; the old forms are still accepted | every package but T6, B3, R1, R2 | 647, 650 to 679 but 659, 681, 684, 685 |
| 2 | The media catalogue and the applications carry their file text as templates | T6 | 659 |
| 3 | The other providers answer their consumers; the old forms are refused; the record says what runs | B3, R1, R2 | 680, 682, 683 |
## What is deliberately not here
- **Secrets in a module's own code** wait on issue 569 (a module's tools run as the tool runner's
@@ -2,7 +2,7 @@
layer: to-be
status: designed
code: [mesh-catalog, mesh-media-catalog, mesh-sdk, mesh-tools, mesh-controller, mesh-host, mesh-lab]
updated: 2026-10-17
updated: 2026-10-16
decisions:
- "ADR 0383"
- "ADR 0384"
@@ -12,10 +12,6 @@ decisions:
- "ADR 0388"
- "ADR 0389"
- "ADR 0390"
- "ADR 0411"
- "ADR 0412"
- "ADR 0413"
- "ADR 0414"
---
# 58 — Agent work as the mesh's own
@@ -25,10 +21,8 @@ bus by a thread seat; a thread handler on an always-on node runs the agent harne
of its own, with the thread's own branch on the forge as its workspace; the agent works through a workspace toolkit
and named tools, never a general command runner. Every person, agent and module is an identity held by one identity
seat, with a role made of grants; the bus checks each login by its signature, and the tool runner checks each call
against the role, tool by tool, by the effect every tool carries. An identity is also its logins: the mesh makes each
person's and agent's machine account on every machine it is present on, gives each its own bus password, and runs a
module for an identity only where the module is set to; everything else runs as the operator.**
(ADR 0383 to ADR 0390, from research 053; ADR 0411 to ADR 0414, from research 057.)
against the role, tool by tool, by the effect every tool carries.**
(ADR 0383 to ADR 0390, from research 053.)
**Acceptance.** The operator starts a thread from the terminal client, an agent on an always-on node works it in a
sandbox, asks a question the operator answers in place, and the thread moves to another node and carries on with
@@ -112,95 +106,6 @@ agent carries a token the identity seat signed for that agent, and the tool runn
refusal names the missing grant; every call is recorded with its identity and thread. A session on an operator's
machine calls as the operator's person identity or as an agent, never through the machine's tool-runner login.
### 5.1 An identity on the machines (ADR 0411, ADR 0414)
A person or agent identity also holds a **login name**, the **machines** it is present on and, for a person, the
login name kept on a machine where it differs. The seat starts with two: the operator's person identity and `nox`, the one
agent; every piece of agent work is a thread of `nox` (§2), and a second agent identity is made only when a rule needs
another role. The agent is `nox` everywhere: its login, its forge account (the forge account `nox-agent` renamed,
keys and team kept), Keycloak, mail and the bus. A login or service account the mesh makes for the operator from now on carries the operator's identity name;
the operator's existing logins and forge account are kept as they are. On a machine where agents ran as the
operator's login, that login stays the operator's and `nox` gets a login of its own; nox's bus account is `agent.nox`. `nox` is also the
operator's terminal command (`mesh-cli`), and prose says which is meant.
An identity's login on one machine is its **machine account**. The controller composes the machine account of every
identity present on a machine into that machine's declaration, and the node-engine applies it with its `user`
resource. A login that exists is adopted under its name, uid and home; the mesh never renames or deletes one. An
agent's machine account has its own home and group, no password, no root group and no sudo rule, and the node-engine
judges on every look that it cannot become root without a person, by the ways ADR 0266 §12 to §17 list. No module declares a login for a
person or an agent: claude-code's `agent-account` resource goes, and the catalogue's check refuses a manifest that
declares one. The node record's operator account and agent account are read from the identities, no longer stated
at the controller's terminal or in the mesh-config snapshot; the machine facts `account`, `agent-account` and their
homes keep their names. A machine names an agent account only where the identity seat marks an agent as its agent
account: the control node, marked `nox`, where the guard and the licence stay as they are. The other machines are
marked with none, so the guard stays off there and the operator's sessions keep the mesh's tools, while the login `nox` serves the
modules run for `nox`. Where a person or an agent is present, its kept logins and the agent account mark change only
through a verb of the identity seat that only the operator calls, on an Approve; an assignment never sets the mark,
because an agent may call the verb that changes an assignment (ADR 0266 §4). The verb, and the controller at composition,
refuse an agent whose login on a machine would be `root`, a person's login there or a well-known service account
(ADR 0266 §5). The self-check judges every agent login,
marked or not. The seed: the operator on every machine, with the operator's existing login kept where it differs from the
identity's name; `nox` on every machine, the agent account on the control node.
```
identity seat controller each machine (node-engine)
───────────── ────────── ──────────────────────────
person operator, machines, composes, per machine: the operator's login (adopted)
the login kept on each the machine accounts present nox, root: never (adopted on the control node,
agent nox, its machines the facts account, agent-account made elsewhere)
each identity's bus password ──► its secret, owned by its login, 0600
```
### 5.2 Run for an identity (ADR 0412)
Every module runs as the operator account of its machine, as today, with no setting; running for the operator is that
default run. A manifest may declare that its module can **run for** an agent identity, at assignment, at start or both: serve one identity with that identity's machine account, home and bus password.
An assignment of such a module may name the identity it runs for, and the module may let the one who starts it
choose: claude-code started for the operator is the default run with the operator's configuration, and started for `nox`
it runs for `nox` with the agent's home. A start
goes down only: a module is started for an identity by a process already running as that identity's login, or by the
operator's own login for an agent; an agent never starts one for a person, and the module refuses it, naming who asked
and for whom. A module may be assigned to one machine once per identity it runs for there, each its own process under
its own login, and the controller keys an assignment by machine, module and identity. The first two to declare it are
claude-code and ssh-client. On a machine the identity seat marks with an agent account, claude-code's assignment is
run for that agent (`nox` on the control node), so its licence and home scope stay in the agent's home, and the controller refuses
one there that is not. ssh-client keeps its default assignment for
the operator wherever `nox` is present and gains a second one run for `nox`, which makes the agent's forge key in the
agent's home and closes issue 989; gitea's provider registers that key on the forge account `nox`. A module run for an identity calls as that
identity and gets no module identity of its own (this narrows ADR 0386 §4, which holds for every other assignment). A module with an account of its
own (`runs-as`, ADR 0259 §8) cannot declare it, and a run for an identity with no machine account on that machine is
refused at composition and at start. ADR 0266's guard
stands: on the control node, a session started for the operator gets the operator's configuration and its tool calls are still
refused.
### 5.3 A bus password per identity (ADR 0413)
Each person and agent identity has a bus account of its own (`person.<name>` for the operator, `agent.nox`), whose memberships are made
from its role, and its password is a secret owned by its machine account at mode 0600 on each machine it is present
on, which no other account but root can read. A person's password is placed only on a machine that names an agent
account of its own judged unable to become root, by a verdict at most fifteen minutes old; on the other machines, where agents can still run as the operator,
it is not placed, and the controller says so. A module run for an identity reaches the bus with that password, can do
only what the identity's role allows, and every call is recorded as that identity's. Nox's role never holds an approval
grant, a trusted party's grant or a dangerous tool named in it (ADR 0387 §4 does not apply to it), and the identity seat
refuses one; each dangerous act of nox's is asked one by one. The break-glass agent (§6) keeps its own rules and gets
no login and no bus password from this. This changes ADR 0259 §8, which kept every bus credential out of an agent's account; the rest of §8 stands.
The password lives in today's list of bus accounts and is replaced by a signed login for the same role at the bus
switch (§7, §8).
### 5.4 Traced to GENESIS
Checked on 2026-10-17 (playbook 02, step 1); no conflict was found.
- [Mission](../../00-META/mission.md): agents "hold identity", and there is one kind of participant, some of them
human; persons and agents are held alike, with the same fields, and differ in what their role allows, which the
mission calls modality. "The mesh brokers credentials": the bus password per identity and the service accounts made
from it. "The mesh owns the truth": logins are derived from identities, no longer stated per node. "Dogfood": logins
go through the node-engine's existing resource.
- [Context](../../00-META/context.md): "Identity is mesh-internal", with no directory and no SSO, so the identity seat
holds it and Keycloak only receives accounts; "Nodes are personal" and the mesh is a guest, so existing logins are
adopted, never renamed; human agents are few, often one, so the seat holds two identities and one agent.
- [Effect](../../00-META/effect.md): a human agent's environment is part of the mesh, its login included.
## 6. Effects, asks and break-glass (ADR 0387)
Every seat verb and module tool is one permission and carries one **effect**, set in its module's manifest:
@@ -251,36 +156,12 @@ carry them:
`research/053-signed-bus-logins`, loses nothing and leaves every identity on the new bus.
5. The acceptance sentence at the top, end to end.
From research 057 (ADR 0411 to ADR 0414):
6. The identity seat lists the operator and `nox` with their machines, kept logins and the control node's agent account mark; a
create or a presence change by any caller but the operator's Approve is refused.
7. On every other machine a login `nox` exists; on the control node the login `nox` is adopted with its uid and home unchanged;
on every machine the node-engine judges `nox` unable to become root; a login `nox` given a sudo rule in a test is left
unaltered and the self-check raises `agent-can-become-root` for it, on a machine that marks no agent account too;
the operator's existing logins are
unchanged.
8. No manifest in the catalogue declares a login for a person or an agent; the catalogue's check refuses one.
9. For every machine, the declaration before and after differs, for the modules not set to run for an identity, only
in the machine accounts' `user` resources (now the controller's, and `nox` added on the other machines); claude-code on the control node, run for `nox`, places the same
files in the same home; on the other machines the guard stays off and the operator's sessions keep the mesh's tools.
10. A session started for `nox` on the control node gets the agent's home configuration and one started for the operator the
operator's, with no tool call through; a session as `nox` that starts claude-code for the operator is refused; with
ssh-client run for `nox`, nox's key is on the forge account `nox` and the session pushes a branch over SSH as `nox`.
11. Nox's bus password is owned by `nox` at mode 0600 on every machine nox is present on; a call with it outside
nox's role is refused; a grant test finds no approval grant, trusted party's grant or dangerous tool in nox's
role; the operator's password is placed only on a machine with an agent account judged root-free within fifteen
minutes, and the controller says so for every other.
12. The forge account `nox-agent` is renamed `nox`, keeps every machine's key, stays in the agents' write team, and
every `forge-account` entry names `nox`.
## 11. Milestones
Each milestone is one feature step (playbook 07), usable as soon as its delivery plan is done, and starts when
the previous one's is. What it replaces is in the as-is documents
[15 — The agent and its licences](../00-as-is/15-the-agent-and-its-licences.md) and
[16 — Logins on the bus](../00-as-is/16-logins-on-the-bus.md), and for milestones 11 to 14
[17 — Accounts on a machine](../00-as-is/17-accounts-on-a-machine.md). The issues are opened once the operator confirms
[16 — Logins on the bus](../00-as-is/16-logins-on-the-bus.md). The issues are opened once the operator confirms
the milestones, and their numbers written here.
**Confirmed by the operator on 2026-10-16.** One issue per milestone: 1 is issue 878, 2 is 879, 3 is 880, 4 is
@@ -305,18 +186,3 @@ and the issue tracker takes only registered owners. The building waits until the
The front ends' later parts, the inference seat and the other agent harnesses are tracked by issue 850; the
seat-sense "role" in older designs by issue 872.
**Milestones 11 to 14, from research 057, confirmed by the operator on 2026-10-17.** One issue per repository
part, opened at the confirmation, so a fixing agent per repository can start when its step does. Milestone 11
waits for nothing; 12 waits for milestone 6.
| # | Outcome, in one sentence | Proves | Repositories and their parts (issues) | Depends on |
|---|---|---|---|---|
| 11 | The agent's forge account is `nox`, with its keys and its place in the agents' write team kept | test 12 | mesh-catalog gitea: the rename, keys registered on `nox`, every `forge-account` entry (993) | — |
| 12 | The mesh makes the operator's logins and nox's on every machine from the identities, and judges nox unable to become root everywhere | tests 6 to 9 | identity module: logins, machines, kept logins, the agent account mark, the operator's verb (994); the node-engine (`mesh-host`): existing logins adopted, every agent login judged (995); mesh-controller: machine accounts composed, node accounts read from the identities, the terminal lines retired (996); mesh-catalog: claude-code declares no login, the catalogue's check refuses one (997) | 6 |
| 13 | The operator and nox each reach the bus with their own password, held to their role; the operator's only where no agent runs as the operator | test 11 | mesh-controller: the operator's and nox's bus accounts, secrets at 0600, the fifteen-minute verdict (999); identity module: nox's role refuses approvals and dangerous tools (998); until milestone 7 builds the asks, a dangerous act of nox's is refused, not asked | 12 |
| 14 | The operator starts a claude-code session for nox or for themself, and nox pushes over SSH with its own key | test 10 | mesh-controller: run for, assignments keyed by identity, the refusals (1001); mesh-sdk: calls as the identity, a start goes down only (1002); claude-code: started for nox or for the operator (1003); ssh-client and gitea: nox's own key, closing issue 989 (1004) | 11, 13 |
The agent's and the operator's accounts in Keycloak and mail (ADR 0414 §2 and §3) follow milestone 13 and are
tracked by issue 1000, which needs a design section first. A second agent identity is made only when a rule
needs another role (ADR 0414 §1); no milestone makes one.
@@ -1,317 +0,0 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog]
updated: 2026-10-22
decisions:
- "ADR 0440"
- "ADR 0378"
- "ADR 0379"
- "ADR 0359"
- "ADR 0361"
- "ADR 0138"
- "ADR 0167"
- "ADR 0439"
- "ADR 0201"
- "ADR 0436"
- "ADR 0458"
- "ADR 0459"
- "ADR 0472"
- "ADR 0461"
- "ADR 0462"
- "ADR 0278"
- "ADR 0515"
- "ADR 0518"
---
# 59 — The mail web client
**The operator's mail, every account of it, in a browser on the mesh's own machines. A module of its own,
`mesh-mail-web`, holds the seat `mail-webclient`; it renders pages on the server in Go and reaches mail only through
`mail-store`'s verbs, under a caller the operator grants account by account. Its route reaches the private network
only, under its internal name, and the operator signs in with Keycloak. A mail written there goes out only on the
operator's Approve, as every mail the mesh sends does.**
(ADR 0440, building on ADR 0378 §5 and ADR 0379 §2.)
**Then the model reads the mail for the operator.** A module of its own, `mesh-mail-reader`, holds the seat
`mail-reading`: it reads each new mail in batches, keeps a mail entry for it (its mail group, one line, its mark),
tells the operator on Telegram of the mail that matters in a mail signal, shows a scam warning on the page only, and
learns from the operator's corrections as reading lessons; it never changes the mailbox and keeps no mail text.
The pop-up for every new mail retires once mail signals
work. Later, once the mesh's threads exist, the operator asks questions about their mail in a mail conversation, a
thread with a mail context. Betterbird is removed once mail reading works on the laptop and the workstation.
(Research 066; ADR 0458 to 0462. The words are the glossary's Mail domain.)
**Acceptance.** In a browser on the laptop and on the workstation, the operator signs in, browses, searches and reads the mail of
every account `mesh-mail` holds, opens an attachment, and writes a mail that goes out on their Approve on Telegram;
the same page is not reachable from a machine outside the private network. A new mail that matters reaches the
operator's phone as one mail signal and stands at the top of the mail page in its mail group; a mail that does not
matter gives no pop-up and no mail signal anywhere.
## 1. The parts
```
browser (laptop, workstation) ── https://mail.<node>.internal ──▶ route proxy (internal reach only, the mesh's own certificate)
│
▼
mesh-mail-web (seat mail-webclient, once for the mesh)
│ sign-in: oidc-client ──▶ Keycloak
│ pages rendered on the server, a mail shown as text
▼ over the bus, as caller <machine>.mesh-mail-web
mail-store (mesh-mail): mail_accounts · mail_search · mail_read
mail_send ──▶ the operator's Approve (Telegram)
mail_send_status
mesh-mail's account bucket (ADR 0439 §2): folders and their counts
```
- **`mesh-mail-web`** is built from `mesh-mail`'s source, as `mesh-mail-notifier` is, and is assigned to the machine
that runs `mesh-mail` (the control-node today). It keeps no mail: no database, nothing on disk (ADR 0440 §1, §4).
- **`mesh-mail`** gains its account bucket (ADR 0439 §2, issue 1255) and a caller check on `mail_send_status`
(ADR 0440 §7, issue 1261), both released with `mesh-mail-web`. It
answers `mesh-mail-web` only for the accounts its trusted setting `grants` gives `mesh-mail-web` (ADR 0359 §4,
ADR 0378 §13). `mail_send_status` becomes part of `mail-store`'s protocol (ADR 0440 §7).
- **The browser never reaches the bus.** The bus has no WebSocket listener or sign-in for a person yet (research 036,
gap G1), so every page is HTML from `mesh-mail-web` (ADR 0440 §5).
## 2. Reachable from the private network only (ADR 0440 §2)
The operator's rule of 2026-10-15: a web UI for mail is reached only over the mesh's private network, never from
outside. Two things hold it:
1. **Its route's reach is `internal`** (ADR 0138 §7). Reach is a setting of the machine a module runs on, keyed by
port, and with none the route proxy composes a public name as well as the internal one. So `mesh-mail-web`'s
`reach` is set to `internal` on its machine before it is assigned there. The route proxy then composes only
`mail.<node>.internal`, serves it only to the mesh's own machines (ADR 0167 §6), and certifies it with the mesh's
own authority.
2. **The module answers only its internal name.** A request whose host is not `mail.<node>.internal` is answered
404, so a public name composed by mistake shows nothing.
Measured on 2026-10-19: the laptop and the workstation hold the mesh's authority (`mesh-internal-ca.crt`) as a trust anchor, and
Firefox on both reads the system's trust through p11-kit, so the page opens there without a warning. Away from the
private network (the laptop travelling without its tunnel) the page is unreachable; Betterbird stays the offline client
until it is removed (milestone 3, §11).
## 3. Signing in (ADR 0440 §3)
`mesh-mail-web` requires `oidc-client` from Keycloak, as grafana does, and uses the authorisation-code flow. It answers
no page, attachment or send to a browser that has not signed in, and lets in only the identities its trusted setting
`operators` names (nobody by default). The session cookie is `Secure`, `HttpOnly` and `SameSite=Strict` and ends
after 12 hours. Every form that changes something (today only the send) carries a token bound to the session.
The operator chose this on 2026-10-19: without it, any process on any mesh machine, agents included, could read every
account through the page and get around `grants`.
## 4. The pages
| page | what it shows | how |
|---|---|---|
| accounts | every granted account, its state, its folders and their counts | `mail_accounts`; the folders from `mesh-mail`'s account bucket, only of an account `mail_accounts` answered (the bucket's read is wider than the grant, ADR 0440 §4) |
| folder | one folder's mails, newest first, in pages of 50 | `mail_search` with the folder; the next page asks for mails before the last one shown |
| search | mails across every granted account by words, sender, recipient, date, attachment | `mail_search`'s filters (issues 1228-1232) |
| mail | one mail: its headers, its body as escaped text, its attachments | `mail_read` |
| attachment | one attachment, as a download | `mail_read` with the attachment's key |
| write | from (a granted account), to, subject, body; after sending, the Approve's state | `mail_send`, then `mail_send_status` |
- **A mail is shown as text** (ADR 0440 §5): `mail_read` turns HTML into text, the page escapes it, and nothing
remote is loaded. An attachment answers `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`.
Every page carries a content security policy that allows no script or style from elsewhere.
- **A send is an ask** (ADR 0361 §2): the page says the ask is raised, then follows `mail_send_status` (ADR 0436
§12's states, now part of `mail-store`'s protocol, ADR 0440 §7) until a final one: sent, failed, refused, not
proven, declined, expired or unknown. The operator's Approve comes on Telegram, as for every mail the mesh sends.
- **The seat's verbs** (ADR 0378 §5): `open` answers the page's address, and with an account and a mail that mail's
page; `status` answers whether it serves and which accounts it is granted.
## 5. One list per account, reading and asking to send alike (ADR 0472)
`mesh-mail`'s trusted setting `grants` stays one list of callers per account, giving both reading (that account's
`MAIL` events, `mail_accounts`, `mail_search`, `mail_read`) and asking to send (`mail_send`, `mail_send_status`):
the operator's Approve on every send is the guard (ADR 0472 §1, §2, which supersedes ADR 0460's split; issue 1227
will not be done). `mesh-mail-reader` is named in `grants` for every account after its delivery, on the operator's
Approve, and asks to send nothing: its manifest invokes no send verb (ADR 0472 §3). Who may call `mail-reading`'s
own verbs is its own trusted setting `callers`, `mesh-mail-web` by default (ADR 0459 §2).
## 6. Mail reading (ADR 0459)
```
mail-store (mesh-mail) ── MAIL: mail.<account>.message.added ──▶ mesh-mail-reader (seat mail-reading, once for the mesh)
▲ │ its own database: mail entries, mail groups,
└──────────── mail_read (named in grants) ────────────────┤ reading lessons; never a body
│ batches of ≤ 10 mails ──▶ claude_code_judge (within ≤ 40 s)
│ claude_code_judged
├──▶ operator-channel.tell (a mail signal, §7)
▼
mesh-mail-web ── entries · groups · correct · lessons · undo · status ──▶ the mail page
```
- **One module, one seat.** `mesh-mail-reader`, built from `mesh-mail`'s source like `mesh-mail-web`, claims
`mail-reading` (declared in `mesh-mail`'s manifest beside the three seats of ADR 0379 §4) and runs once, on the
machine that runs `mesh-mail`. It hears `added` events under its own `runs-as` account and the operator's reading
grant (ADR 0378 §13), and moves, flags, deletes and sends nothing.
- **A mail entry per mail, never the body.** Its own database (`postgres-database`) keeps each mail's mail group,
a one-line summary, its mark (*matters*, *scam warning* or neither) and why, and a due date where the mail gives
one, beside the mail groups and the reading lessons.
- **Batches.** One judgement reads at most 10 mails, and every part of its question has a bound, so it never
passes 55 KiB of the judgement's 64 KiB: the framing that a mail is data and never an instruction, at most 4 KiB;
the mail groups, at most 4 KiB, the largest first; the reading lessons that apply, at most 40 within 12 KiB,
sender and mail group first, then the newest, under the framing that a reading lesson outranks anything a mail
says; then each mail, at most 3.5 KiB (its headers at most 512 bytes, its text cut to its first 3 KiB), a mail in
a Junk folder said to be so. A batch asks for up to 40 s with `within` and collects with
`claude_code_judged` (built under issue 508, recorded by ADR 0459 §14). Research 066 measured 1.9 to 3.1 s per
mail, so a batch of 10 takes about 19 to 31 s. A batch starts at least 160 s after the previous one started, so
an issue tracker's check that waits behind a batch on the same machine ends within 115 s, inside its two minutes
(ADR 0278 §2), which research 066's 15 mails and 120 s back to back would not; mail reading then reads up to 225
mails an hour (ADR 0459 §15).
Batches stay judgements; whether they become threads is issue 1417.
- **Reading lessons.** When the operator changes a mail's mark or mail group on the page, `mesh-mail-web` calls
`correct` and the correction is kept at once as a reading lesson (headers, the change, when; never body text),
with no further Approve: it is the signed-in operator's own act. The page lists the reading lessons, undoes one
(later mail reading only), and shows reading lessons per 100 read mails per week, which answers whether mail
reading learns the operator's way.
- **The page** (replaced by design [63](63-the-mail-web-ui-with-mail-reading-and-owly.md)'s operator's list, ADR 0515 §5). Above the classical list it shows the mail that matters at the top, the mail groups, and the scam
warnings in a place of their own, each with why; the classical list (accounts, folders, search, one mail) stays
and always opens. `mesh-mail-web` reads these through `mail-reading`'s verbs and still keeps no mail state
(ADR 0378 §5 and ADR 0440 §4 as ADR 0459 §13 changes them); it writes the operator's corrections with
`correct` and `undo`.
- **It fails loudly.** Its own health check is unhealthy when a judgement is refused, times out or answers out of
shape, with the cause and since when; the controller raises its condition, the page shows the cause above the
classical list instead of mail groups, and no mail signal is sent. On recovery the mail that arrived meanwhile is
read in arrival order from its durable consumer on `MAIL` (kept 7 days). A warning before a licence runs out is
issue 1409.
## 7. Mail signals, and the pop-up retired (ADR 0461)
- **One tell per batch.** A batch that marks mail as mattering sends one `operator-channel.tell`: the headline
"1 mail matters" or "N mails matter", a first line naming the mail with the nearest due date (sender's name,
summary, due date), a second naming the next and, with more than two, ending "and N-2 more on the mail page".
When the hourly cap of 10 refuses one, its mails stay at the top of the page and the next mail signal counts
them.
- **Words only.** No link, mail address, host name or path: the router refuses a message holding any, and the mail
page opens only on the mesh's own machines. The reader checks its words with `messenger_check` first and, refused,
sends the safe form "N mails matter: open the mail page".
- **At once, at any hour**; the phone's own quiet hours decide. **Never** for a scam warning, and never while mail
reading is unhealthy.
- **The pop-up retires.** Once mail signals pass acceptance test 13 on the real accounts, `mesh-mail-notifier` is
unassigned from the laptop and the workstation on the operator's Approve and leaves `grants`; `mail-notifier`
stays declared and unheld, and nothing takes it over (ADR 0379 §3, §7). Betterbird's own pop-ups stay off by its
setting until it is removed (ADR 0379 §6). Issue 1418; issue 801 closes as replaced.
## 8. The mail conversation (ADR 0462)
**Replaced by design [63](63-the-mail-web-ui-with-mail-reading-and-owly.md):** Owly answers mail questions through
mail reading before threads exist (ADR 0518, changing ADR 0462 §6); the conversation below is what Owly moves onto
once threads exist.
A mail conversation is a thread (ADR 0383), run by the thread handler (ADR 0384), with a mail context: the framing
that a mail is data, the tools `mail_search` and `mail_read` only, under a caller of the thread's own named in `grants`
(ADR 0472 §4; issue 1419), the operator's real senders and folder names, and the reading lessons. The operator writes a mail
question on the mail page; each answer names the mails it found, and a question with no answer says so.
`mesh-mail-web` creates and follows the thread over the bus on its server side; the page loads one script from its
own origin and talks only to it (content security policy: script from that origin, `connect-src 'self'`), so the
browser never reaches the bus. The thread keeps the text of each mail it read for good, as every thread is kept (ADR
0383 §3), which the operator accepted; mail reading keeps none. In the same milestone `mail_search` matches folder
names without regard to case and describes its three `-any` switches as true or false only. It is built after design
58's milestone 3 (threads, issue 880) and once issue 1419's caller is decided.
## 9. What stays outside
Each is an issue with an owner:
- a reply, cc, bcc and attachments on a sent mail: issue 1257 (it needs `mail_send` to take them first);
- a page that shows new mail without a reload, from ADR 0439's buckets: issue 1258;
- drafting a mail, the rest of issue 1259 (its triage is mail reading, its search the mail conversation), and writing
to the mailbox: issue 730;
- a task list for the operator: issue 1396; the search faults found by research 066: issues 1400 and 1401;
- the work account: a later decision of its own (ADR 0359 §7).
## 10. The build's acceptance tests
Tests 1 to 9 are milestone 1's; tests 10 to 17 are research 066's eight, in its order.
1. A request without a session, for a page, an attachment or a send, is redirected to sign-in; an identity not in
`operators` is answered 403; a send form without its token is answered 403; the session cookie is `Secure`,
`HttpOnly` and `SameSite=Strict` and ends 12 hours after sign-in.
2. A request whose host is not the internal name is answered 404.
3. Against a stand-in `mail-store` and a filled account bucket on the real bus, the pages call only
`mail_accounts`, `mail_search`, `mail_read`, `mail_send` and `mail_send_status`, list folders from the bucket,
page by date, and a send shows its state; the manifest declares no database and no writable directory.
4. A mail whose body holds `<script>` and a remote image is shown as text; an attachment is a download with
`nosniff`; every page carries a content security policy allowing only its own origin.
5. After a mail is indexed, `mesh-mail`'s account bucket changes only the keys that describe it, within ADR 0439's
bounds (issue 1255's own tests).
6. The declaration for its machine composes `mesh-mail-web`'s route with its internal name only (`mesh-controller.plan`).
7. `mail_send_status` answers each state of ADR 0436 §12, and `unknown` for an id it does not know and for another
caller's send (a unit test of `mesh-mail`, issue 1261).
8. The seat's verbs: `open` answers the page's address, and with an account and a mail that mail's page; `status`
answers whether it serves and the accounts it is granted (a unit test of `mesh-mail-web`, issue 1262).
9. The acceptance sentence at the top, live: in a browser on the laptop and on the workstation, for every account.
10. A new mail on each account has a mail entry, in a mail group, within 5 minutes of its mail event; a test searches
`mesh-mail-reader`'s database for a known body line and finds none.
11. The reader asks no `mail_send` for any account, and no mail is moved, flagged or deleted by it.
12. (Milestone 4.) On the mail page, a mail question by a person, by a time range and by "what needs a reply" finds
its target mails, research 066's three failed kinds included; a question with no answer says so; the conversation
is a thread readable through the thread seat; and the browser makes no request but to the mail page's own origin.
13. A batch with one mail that matters sends exactly one `tell` naming it, with no URL, mail address or host name in
its words, and the mail is at the top of the page; a sender whose name carries a host name still gives a
mail signal, in the safe form.
14. Research 066's 20 steering mails, read again, mark no injected words as mattering, and every phishing decoy that
is marked is a scam warning, which sends no `tell`.
15. A reading lesson given on a real mail changes how the next mail of that sender is noted; undone, it no longer
does; the page lists it.
16. With the judgement refused (the reader's licence binding removed in the integration test), the condition is
raised with its cause, the page shows it over the classical list, no `tell` is sent, and after recovery the mail
that arrived meanwhile is read in arrival order.
17. With `mesh-mail-notifier` unassigned, a new mail that does not matter shows nothing on the laptop or the
workstation, and one that matters gives one mail signal.
Tests 1 to 4 are one integration test that starts the module whole against the real bus (playbook 07 step 3 item 4);
tests 5 and 7 are `mesh-mail`'s own and test 8 `mesh-mail-web`'s, unit tests; test 6 is read before the module is
assigned; test 9 is proven live after the delivery.
Tests 10's and 11's checks, 13 (with a stand-in router) and 16 are integration tests that start
`mesh-mail-reader` whole against the real bus and store, with stand-ins for the seats it asks (issues 1431, 1524); the batch's budget, the seat's verbs and the
mail signal's words are unit tests; tests 10, 13, 14, 15 and 17 are proven live after milestone 2's delivery, test 13
before `mesh-mail-notifier` is unassigned; test 12 with milestone 4.
## 11. Milestones
| # | Outcome, in one sentence | Proves | Repositories and their parts | Depends on |
|---|---|---|---|---|
| 1 | In a browser on the laptop and the workstation, the operator signs in and browses, searches and reads every account's mail with its attachments, and writes a mail that goes out on their Approve | tests 1 to 9 | mesh-catalog: `mesh-mail`'s account bucket (issue 1255, ADR 0439 §2); the module `mesh-mail-web` (manifest, sign-in, the pages, the send, the seat's verbs, the integration test) | — |
| 2 | Each new mail is read into a mail group on the mail page; the mail that matters reaches the operator's phone as one mail signal, a scam warning shows on the page only, the operator's corrections become reading lessons, and the pop-up for every new mail is gone | tests 10, 11, 13 to 17 | mesh-catalog: the seat `mail-reading` declared in `mesh-mail`'s manifest; the module `mesh-mail-reader` (batches, mail entries, reading lessons, mail signals, health, the integration test); `mesh-mail-web`'s mail groups, mail signals, scam warnings and reading lessons; `mesh-mail-notifier` unassigned (issue 1418) | 1 |
| 3 | Betterbird is gone from the laptop and the workstation, its profile and mail kept in the home | ADR 0379 §2 | mesh-catalog: Betterbird unassigned on the operator's Approve, then the `betterbird` module removed from the catalogue (issue 806) | 2 |
| 4 | **Replaced by design 63** (2026-10-22): on the mail page the operator asks questions about their mail and gets the mails that answer them, in a mail conversation kept as a thread | test 12 | mesh-catalog: `mesh-mail-web`'s mail conversation as a client of the thread seat; the thread's mail tools under a caller of their own (issue 1419); `mesh-mail`'s folder case and `-any` switches | 2 (the reading lessons); design 58's milestone 3 (issue 880) |
**Milestone 1, confirmed by the operator on 2026-10-19, is delivered;** its live proof on the laptop and the
workstation (a search, an attachment, one send on the Approve) is still the operator's. Its issues, one repository
part each, in their order: 1255 (`mesh-mail`'s account bucket), 1261 (`mail_send_status` answers only its caller),
1262 (the module), 1263 (sign-in), 1264 (accounts, folders and mails), 1265 (search), 1266 (a mail and its
attachments), 1267 (writing a mail), 1268 (the integration test). After its delivery four values were set, each on
the operator's Approve: `mesh-mail-web`'s `reach` on its machine (`internal`), its assignment there, its
`operators`, and its caller in `mesh-mail`'s `grants` for every account.
**Milestones 2 to 4, confirmed by the operator on 2026-10-20.** Their issues, one repository part each, in their
order (each depends on the one before unless said):
- Milestone 2 (its order is said for each): 1426 (the seat `mail-reading` and the module `mesh-mail-reader`), 1517
(its database; after 1426), 1518 (hearing new mail, catching up in arrival order; after 1517), 1519 (its health;
after 1517), 1427 (a batch's question within its bounds; after 1426), 1520 (running batches; after 1427, 1518 and
1519), 1521 (the verbs `entries`, `groups`, `status`; after 1520), 1428 (`correct`, `lessons`, `undo`; after 1521),
1429 (a mail signal's words; after 1427), 1522 (sending it; after 1429 and 1520), 1430 (what the mail page shows;
after 1428, 1521 and 1522), 1523 (corrections and reading lessons on the page; after 1430 and 1428), 1431 (the
reader's integration test; after 1430 and 1522), 1524 (the integration test of failing loudly; after 1431 and
1523), 1418 (`mesh-mail-notifier` unassigned; after 1524, the delivery and test 13 live). Issues 1517 to 1524 were
split from 1426 to 1431 at the prepare step; issue 1227 (the grants split) will not be done (ADR 0472).
- Milestone 3: 806 (Betterbird removed; after 1418).
- Milestone 4: 1433 (`mail_search`'s folder case and `-any` switches; depends on none), 1419 (the thread's
caller; a decision first, after design 58's milestone 3, issue 880), 1432 (the mail conversation;
after 1431, 1419 and issue 880).
Milestone 2 is one release of `mesh-catalog`: the new seat, the new module and the page change ship together, and
`mesh-mail-reader` is named in `grants` after that delivery, on the operator's Approve; `mesh-mail-notifier` is unassigned only after test 13 passes live. Milestone 3 is issue 806.
ADR 0379 §2's bar for removing Betterbird is milestone 1's; keeping Betterbird until mail reading (milestone 2) works,
and not until the mail conversation, is the lead's proposal, for the operator to confirm with the milestones
(playbook 04, step 5). **Milestone 4 is replaced by design [63](63-the-mail-web-ui-with-mail-reading-and-owly.md)**
(2026-10-22): Owly answers mail questions through mail reading before threads exist (ADR 0518), and moves onto a
thread once they do. Issue 1432 will not be done; issue 1433 moves to design 63's milestone 3; issue 1419 stays
open for the move onto a thread.
-214
View File
@@ -1,214 +0,0 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog, mesh-sdk, mesh-controller]
updated: 2026-10-21
decisions:
- "ADR 0464"
- "ADR 0467"
- "ADR 0468"
- "ADR 0474"
- "ADR 0475"
---
# 60 — The brother module
**One module serves the operator's Brother multifunction device: its scanner and its printer are one machine on
the network, so one module, `brother`, holds both seats, `scanner` and `printer`. It scans the glass or the
document feeder into a PDF, keeps every scan in its own bucket and announces it on the bus, prints a document in
one call, reads the device's state and its admin pages, restarts it, and changes the settings on the admin pages
where a mistake cannot lock the operator out or open the device. Only the module's device process ever holds the
device's admin password or the bucket's key.**
(ADR 0464, ADR 0467 and ADR 0468 are in force; ADR 0468 is being built, milestones F1 to F3 below. Proposed, and
not in force: ADR 0475 for the feeder, multi-page and several-document scans; ADR 0474 for
reading every page, configuring, the refused pages and the restart.)
**Acceptance.** The operator, or an agent through the mesh MCP server, scans a document from the glass and finds it
cropped in the scan folder; scans five sheets from the feeder on both sides and finds one PDF of ten pages; prints
that PDF from a file in one call; reads any admin page (ADR 0474, as proposed); changes a setting on an allowed page and is told the old and the new
value; and is refused, with the reason, when asking to change the admin password or the network settings.
## 1. The parts
```
caller (an agent, a module, the operator)
│ scanner.scan · scanner.status · scanner.list · scanner.document
│ printer.status · printer.print · printer.upload · printer.settings
│ printer.configure · printer.restart
▼
brother — tool side (served by the node's tool runner, as the operator account)
│ holds no credential
│ status reads: IPP and eSCL, no login ───────────────────────────▶ the device
│ every other verb: an ask on the work queue device-asks
▼
brother — device process (an account and a bus account of its own)
│ holds the admin password and the bucket's key, and nothing else does
├── eSCL ─────────────────────────────▶ the scanner
├── IPP ─────────────────────────────▶ the printer
├── HTTPS to the pinned certificate ───▶ the admin pages
├── S3 ─────────────────────────────▶ the module's bucket (minio)
└── its answer in the state device-answers ──▶ back to the tool side
```
The two seats are held once for the mesh. A seat names the role, so a later scanner or printer module claims the
same seat and no caller changes (ADR 0467 §2).
The module has two halves. The **tool side** answers the verbs. It runs as the node's operator account, so the
scans it files in the scan folder are the operator's, and it never opens a credential. The **device process** runs
as an account of the module's own with a bus account of its own (ADR 0436 §1), holds no seat and serves no tool. It
alone reads the admin password and the bucket's key, and it alone does the work that needs either. A verb that
needs a credential puts an ask on a work queue and reads the device process's answer back from the module's state,
waiting at most the verb's bound (ADR 0468 §1). The reads that need no credential, the device's state over IPP and
the scanner's over eSCL, stay on the tool side.
The admin password is the operator's own, given once at the operator's desk and kept in the vault; the mesh never
generates or rotates it (ADR 0467 §4). The device process speaks HTTPS to the admin pages and trusts only the
certificate whose SHA-256 fingerprint is pinned in the module's config; any other certificate is refused in the
handshake, before a password is sent (ADR 0467 §3). The module's pin tests check it: a certificate with another
fingerprint is refused and nothing is sent.
## 2. Scanning
What ADR 0464 decided is the glass, one page, one PDF page. ADR 0475 (proposed) widens it to the feeder, to several
documents on the glass and to documents of several scans; until it is accepted, only ADR 0464 holds.
- **The glass.** One page over the whole glass. The document lies on the lid, which is a white of its own, so the
module measures the lid where no document lies and cuts the document out of it with a small margin. When it finds
no document, or one that fills the glass, it keeps the whole page and says it did not crop (ADR 0464 §3).
- **Several documents on the glass** (ADR 0475, proposed). Documents lying apart on the glass are each cut out and
filed as a PDF of their own.
- **One document of several glass scans** (ADR 0475, proposed). A caller starts a document, adds a glass scan to it
as often as it needs, and finishes it, and the pages become one PDF. A document left unfinished is filed whole
after 30 minutes, so no scan is lost.
- **The feeder** (ADR 0475, proposed). The feeder scans whole sheets, one side or both, and every page of the run
goes into one PDF, in the order the scanner gave them: one object in the bucket and one announcement, with its
page count. A feeder page is never cropped. A run that is cut off before its end is an error, never a short PDF
said as success; a unit test checks it.
- **A long run** (ADR 0475, proposed). A feeder run may take longer than a call through the mesh MCP server may
wait. Such a call answers that the scan started, with an id, and the caller reads its outcome by that id. A scan
that was announced is always filed.
- **The real resolution.** The module reads the resolution from the image it receives, never from what it asked: a
scanner may answer a request for 300 dpi with 200 dpi (ADR 0464 §2).
**A lesson for every device the mesh speaks to.** The scanner ignored every scan setting at first: whatever the
request asked, it ran its own default job. The cause was the request's body: the device ignores a body that holds a
line break, and the same request written on one line is honoured. A device's protocol implementation may be
stricter, or stranger, than its standard; when a device ignores a well-formed request, the form of the request
(line breaks, whitespace, the order of fields) is the first suspect, and a capture of what a working client sends
is the evidence.
## 3. Printing
A caller prints in one call: `printer.print` takes exactly one of a key in the bucket (`object`), a file on any
machine of the mesh (`path` with `node`) or a web link (`url`), with the copies, the sides and the paper (ADR 0468
§5). A file on a machine is sent into the bucket by that machine's node-files `upload`, which refuses the secret
places a read refuses, and printed by its key (ADR 0468 §9). A web link is fetched by the device process, at most
60 s, 100 MiB and 5 redirects, and never from an address inside the mesh or a private network (ADR 0468 §6). A
document that is neither PDF nor JPEG is turned into a PDF by the seat `document-converter`, which only-office holds
(ADR 0468 §7). The device process then sends the document to the printer in a format the printer itself lists as
accepted, read before the job.
**No document crosses the bus.** The bus carries a document's key, size, SHA-256 and type; the bytes move over
HTTP between a party and the object store, on a signed link of at most 10 minutes for one object and one method
(ADR 0468 §3). The device process signs links for the module's own steps with its binding's key; every other
party asks `object-store.link`, which minio serves and signs with a key limited to that bucket, for a caller that
brother's `shares` names (ADR 0468 §8). The parts verbs `printer.upload` and `scanner.document` still serve until
issue 1569 removes them.
## 4. The bucket and the events
Every scan is kept once in the module's own bucket under its SHA-256, and announced with the event
`brother.scan.added` once it is stored: its key, size, page count, size in millimetres, resolution and colour mode,
never the image. The scan folder is one consumer of the stored scans: once the scan is stored and announced, the tool side
reads it by a link the device process signed and files it there as the operator; the device process never writes
into the operator's home (ADR 0468 §4). The module deletes nothing from the bucket; the operator removes what they no longer want
(ADR 0468 §2, §4).
The other events are `brother.printer.restarted`, when the device answers again after a restart (ADR 0467 §6), and
`brother.printer.configured`, one for each setting changed (section 5).
## 5. Settings: read and configure
**Reading.** `printer.settings` reads one admin page: the device process logs in, reads the page and logs out. It
answers the page's forms with each field's current value (a radio group's checked button, a checkbox's state, a
select's chosen option) and the values each field allows; the page's links and its hidden fields only on ask. A
password field's value is never given. ADR 0467 §5 decides the read for the pages of the scan and restriction
settings; ADR 0474 (proposed) widens it to every page, and the menu page's links are the map of the rest.
**Configuring** (ADR 0474, proposed). `printer.configure` takes one page and the fields to set on it, by name. The device process logs in
and reads the page as the printer serves it, with a fresh form token. It checks each named field: the field exists
in one form of the page, and its value is allowed for its type (one of a radio group's or a select's values, on or
off for a checkbox, a text within its maximum length). A password, hidden or file field cannot be set. When any
check fails, nothing is sent and the answer names each refused field with what it allows. Otherwise the process
posts the form as the page served it: its own hidden fields, the named fields at their new values, and every other
field at its current value, so nothing the caller did not name changes. It reads the page back, answers each changed
field's old and new value, and says a field the printer did not take as not taken. Then it logs out.
```
caller ──configure {page, set}──▶ tool side ──ask──▶ device process
login ─▶ read page (form, token, current values)
check every named field ──✗──▶ refused, nothing sent
post the whole form ─▶ read back ─▶ logout
◀──answer: old → new per field
brother.printer.configured, one per field
told to the operator: page, fields, who asked
```
**What may not be changed** (ADR 0474, proposed). Some pages are refused, because a mistake on them locks the operator out or opens the
device to the network: the login password, the user restriction and setting lock, the reset menu, the firmware
update and its setup, the security settings, every wired, wireless and Wi-Fi Direct network page, the protocol and
interface pages, and every security page (certificates, TLS, IPsec, the IP and MAC filters). The list is in the
module's code and its tests, not in a config value, so no call can widen it; it is checked against the page asked
and against the address the form posts to; a unit test refuses each listed page. The operator changes such a page in a browser at the device itself.
**Who asked, and who is told** (ADR 0474, proposed). Every change names who asked, the caller the bus proved for the call; a call with no
proven caller is refused. Each changed field is announced as `brother.printer.configured` with the page, the field,
the old and the new value, who asked and when, and the operator is told through the operator channel, once per call.
No password is ever in an answer, an event, a message or a log; a unit test reads each of them for it.
**Restarting.** `printer.restart` is decided by ADR 0467 §5; that it goes through the device's reboot page, posting
that page's own form, is ADR 0474's, proposed. It is the one call that posts to the reboot page, which configure refuses. It is
refused while the device holds a print or scan job, and waits at most five minutes for the printer to answer again
(ADR 0467 §5).
**The module's own defaults stay config.** The scan resolution, colour mode and cropping are the module's config
values, held by the config module and changed through its `config` verb (ADR 0348); they are not trusted values.
Configure changes the device's pages and never the module's config (ADR 0474, proposed), which a unit test checks.
## 6. Health
The device process's health is its unit. The module's health, as ADR 0475 proposes, compares the certificates the
printer serves, without a login, with the pinned one, and is unhealthy, naming the new fingerprint, when the printer
serves one that is not pinned; a printer that does not answer is healthy with a note, and with no printer address set
the module waits for that setting. Until that is accepted, a changed certificate shows only when a verb is refused. A certificate the device
serves that is not pinned is refused, and the refusal names the served and the pinned fingerprint. A device serves
a new certificate after a reset, a firmware update or a certificate's renewal, so a refusal is either that or
another machine answering in its place; only the operator can tell which, and pins the new fingerprint as a config
value. A firmware that moves an admin page is said as a page not found, never as success: a unit test replays a
missing page and expects the error.
## 7. Still open
- **The parts verbs.** `printer.upload` and `scanner.document` carry a document over the bus in parts; issue 1569
removes them once their callers use links, and revises ADR 0475 to ADR 0468.
- **Several printers.** The `printer` and `scanner` seats take one holder; issue 1602 asks for the decision that lets
a second printer join.
- **Restart is not built yet.** `printer.restart` is decided (ADR 0467 §5) and follows `printer.configure`.
- **Not built yet either:** several documents on the glass, a document of several glass scans and the long run's id
(ADR 0475, proposed). The print of a machine's file is ADR 0468's, built in milestone F3.
## Milestones
The milestones build ADR 0468, feature `files-through-the-store`. They are cut by release order: the catalogue's
merge check reads manifests with the controller the mesh runs, and the controller vendors the SDK, so a new
manifest field ships SDK first, then the controller and every other strict reader of manifests (the build modules,
the delivery's indexer), then the catalogue (playbook 07, *a new manifest word*).
| # | Outcome | Proves | Repository | Issues |
|---|---|---|---|---|
| F1 | A manifest may name who else may read or write its bucket, and every module has one helper that signs and streams a store link. Usable only by F2 and F3. | ADR 0468 §3, §8 (the field) | mesh-sdk | 1591 (`shares` on a requires of s3-bucket), 1592 (link signing and streaming) |
| F2 | The controller accepts `shares` and writes it into the consumer's grant. Usable only by F3. | ADR 0468 §8 | mesh-controller | 1593 (take the SDK); depends on F1 |
| F2b | The build modules read a manifest that names `shares`, so a module carrying it builds. Usable only by F3. | ADR 0468 §8 | mesh-catalog | 1657 (mesh-build-go and mesh-build-node take the SDK); depends on F1; the delivery's indexer takes the same SDK in design 47's own work |
| F3 | The operator prints a stored scan, a file on any machine (a .docx on a laptop among them) or a web link in one call, and scans land in the scan folder from the store; no document these verbs move crosses the bus (the parts verbs stay until issue 1569). Depends on F2 and F2b. | the acceptance above; ADR 0468 §1–§9 | mesh-catalog | minio: 1594 (a signing key per bucket), 1516 (`object-store.link`, retiring the root-signed link); mesh-files: 1597 (upload, download); only-office: 1595 (`document-converter.convert`), 1596 (the converter process); brother: 1598 (print by key or a machine's file), 1599 (print a web link), 1600 (convert before printing), 1601 (the scan folder from the store) |
Order inside F3: minio, then mesh-files and only-office, then brother; one pull request of `mesh-catalog`.
@@ -1,75 +0,0 @@
---
layer: to-be
status: designed
code: [mesh-catalog]
updated: 2026-10-21
decisions:
- "ADR 0479"
- "ADR 0468"
- "ADR 0440"
---
# 61 — The print and scan page
**A web page where the people the operator names sign in with Keycloak, print a PDF or a photo and scan from the glass,
and where only the operator manages the printer. It is a module of its own, `print-and-scan`, that
knows only the seats `printer`, `scanner` and `object-store`, so any module holding the printer and scanner seats
works with it unchanged.** (ADR 0479, accepted 2026-10-21.)
**Acceptance.** A person named in `users`, on a phone that is not a machine of the mesh, signs in, uploads a PDF
and finds it printed, and scans the glass and downloads the PDF; that person is refused the printer's settings and
its restart, which a person named in `managers` may use; a browser that has not signed in sees nothing but the
sign-in.
## 1. The parts
```
a browser (a phone or laptop, inside the mesh or not)
│ HTTPS, through the route proxy's public name, signed in with Keycloak
▼
print-and-scan — renders the pages on the server, holds a bucket of its own
│ printer.status · printer.print · printer.settings · printer.configure · printer.restart
│ scanner.scan · scanner.status · object-store.link
▼
the holders of the printer, scanner and object-store seats ── signed links ──▶ the object store
```
The page claims the seat `print-and-scan`, held once for the mesh, with the verbs `open` and `status`. It calls
only seat verbs, never a module's own tool, and knows no device's protocol (ADR 0479 §1).
## 2. Who may do what
The page requires a Keycloak client, as the mail web client does (ADR 0440 §3). Two trusted settings name the
identities let in, by the ID token's user name: `users` print and scan, `managers` also manage. Both are empty by
default. A browser that has not signed in is sent to the sign-in; an identity in neither setting gets a refusal page
that names no holder, document or setting (ADR 0479 §2, §3).
Its route is public, so a person reaches it from a device that is not a machine of the mesh; this is where it
differs from the mail web client, whose route is internal only (ADR 0479 §3).
## 3. Documents
A document goes from the browser to the page over HTTPS, and from the page into its own bucket by a link the object
store signs; it never crosses the bus, the page keeps none on its disk, an upload is bounded at 100 MiB (refused by its declared length, or cut at the bound, which stores nothing), and a signed link never reaches a browser, because the links name the store's
internal name (ADR 0468 §3, ADR 0479 §4). The page takes PDF and JPEG only, judged by their first bytes; any other
type is refused before anything is stored, and converting other types waits on issue 1650 (ADR 0479 §4). To print,
the page names its own bucket and the key; its bucket shares reading with the printer seat, and the printer's holder
asks the object store for the link itself, a process of the holder matching the seat (ADR 0479 §5). A scan is read from the scanner's bucket, which shares reading with the
page, and streamed to the browser as a download; the page offers the feeder and both sides only once a decision in
force gives them to the scanner seat's scan (ADR 0479 §6).
## 4. Managing the printer
Every user sees the printer's state. Reading and changing its settings and restarting it are the printer seat's
verbs, for managers only (ADR 0479 §7). The printer's holder sees the page as its caller, so the page records the
person: before each such call it announces who asked, what and when, and after it the outcome. Restarting is ADR
0467's, and so is reading the settings, which ADR 0474 proposes to widen; changing them is proposed in ADR 0474,
which is accepted and built before the page (ADR 0479 §9).
## 5. Still open
- **Several printers.** The page serves the seats' one holder; issue 1602 asks for the decision that lets a second
printer join, after which the page lets the user choose (ADR 0479 §8).
- **Other types.** A Word, spreadsheet or web document waits on issue 1650's decision (ADR 0479 §4).
- **Order.** The page is built after design 60's milestones F1 to F3 and after ADR 0474 is accepted and built
(ADR 0479 §9).
@@ -1,98 +0,0 @@
---
layer: to-be
status: proposed
code: [okioki]
updated: 2026-10-21
decisions:
- "ADR 0481"
- "ADR 0440"
- "ADR 0479"
---
# 62 — The bookkeeping portal
**A web page on the private network where the operator, signed in with Keycloak, sees the business's income and
expenses as the bookkeeping service holds them now, and what is still to do per quarter. The `okioki` module that
talks to the service moves, whole, from the laptop to the home server, and the page, `okioki-web`, runs beside it.
Bookkeeping data stays on hardware the operator owns.** (ADR 0481, accepted.)
**Acceptance.** The operator, signed in on a machine of the mesh, opens the page's internal name and sees income and
expenses per month and per quarter, the same period a year before beside them, including an invoice made in the
bookkeeping service minutes before; sees the to-do of a quarter, with a quarter the accountant has submitted shown
as locked; and a browser that has not signed in, or an identity not named in `operators`, sees nothing but the
sign-in or a refusal.
## 1. The parts
```
the operator's browser (a machine of the mesh)
│ HTTP through the route proxy's internal name, signed in with Keycloak
▼
okioki-web — renders the pages on the server, keeps no bookkeeping data
│ over the bus (NATS): bookkeeping.summary · bookkeeping.todo · bookkeeping.documents · bookkeeping.transactions · bookkeeping.document
▼
okioki — the one session with the bookkeeping service, on the home server
│ HTTPS, the service's own web API, as its web app calls it
▼
the bookkeeping service (OkiOki)
```
`okioki` claims the seat `bookkeeping`, held once for the mesh, and serves the verbs the page calls beside the tools
it has (`okioki_todo`, `okioki_link`, …). `okioki-web` claims `bookkeeping-web` with `open` and `status`, and calls
only `bookkeeping` verbs, over the bus, never a module's own tool, as the print and scan page calls only seats (ADR 0479 §1).
`okioki-web` opens no connection to the bookkeeping service and holds none of its secrets or settings: only `okioki`
holds the service's password and session (ADR 0481 §1, §7). Both are Go, and both live in the `okioki` repository.
## 2. Where it runs
Both modules run on the home server, which is always on and is the operator's own hardware (ADR 0481 §1). The
laptop sleeps and travels, so a page there would be away whenever the laptop is. `okioki` runs once: the service
password is sealed on the home server for `okioki` alone, given once at its desk prompt, and the laptop's `okioki` is retired on
the operator's Approve once the home server's answers.
Uploading no longer reads a folder on the laptop. `okioki_upload` takes a scan from the scanner's bucket by its key,
or a mail attachment by its account, message and name, and reads it over the bus (ADR 0481 §3); a scan made on any
machine then reaches the service without being copied between machines.
## 3. Who may see it
The home server's route `reach` is set to `internal` before the page is assigned there, so the route proxy composes
only the internal name, and the page answers 404 to any other host (ADR 0440 §2, ADR 0481 §6). It requires
`oidc-client` from Keycloak, as the mail web client does (ADR 0440 §3). A trusted setting `operators` names the identities let in by the ID token's user name; it is empty by
default, so nobody is let in until the operator names themself. Nothing about the page is public.
## 4. What it shows
- **Overview.** Income (outgoing invoices) and expenses (incoming invoices and receipts, refunds subtracted), excluding
VAT, per month and per quarter, the same period a year before beside it; wages and money in and out of the business
account from the bank, by the operator's own tags. `bookkeeping.summary` computes it in `okioki` from the service's
documents and transactions, so the page does no bookkeeping arithmetic of its own.
- **To-do.** Open payments and documents per quarter, as `okioki_todo` reads them.
- **Documents and transactions.** Searchable lists for a period; a document opens as the file the service keeps,
streamed through `bookkeeping.document` over the bus in numbered parts, as `scanner.document` answers (part, parts,
size, SHA-256, data): `okioki` fetches the file from the service, and the page never follows the service's own link.
The page only reads: it calls no verb that changes the service, and keeps no copy (ADR 0481 §4). Keeping the history in a database of the mesh's own is the own
bookkeeping research (issue 1457), and a decision of its own.
## 5. What the accountant has done is never undone
Every tool of `okioki` that changes the service, whoever calls it, reads the booking or document first and refuses
one the accountant has reviewed, approved for sending or booked, naming it and why, and sends nothing (issue 1608,
ADR 0481 §5). The page shows such items as locked.
## 6. In what order it is built
1. **`okioki` on the home server**: the seat `bookkeeping` with its read verbs, uploads from the scanner's bucket and
from mail, the accountant guard (1608); assigned to the home server, its password given at its desk prompt, the
laptop's retired on the operator's Approve.
2. **The read-only page**: `okioki-web` with the overview, the to-do and the lists, behind Keycloak.
3. **Changes from the page** (linking, tagging, marking paid, closing) are not part of this design: they need a decision
of their own (ADR 0481 §4), and would go through the same guarded tools.
## 7. Still open
- **The route proxy on the home server** did not answer its tools on 2026-10-21; whether it serves an internal name
there is checked before step 2.
- **The guard's fields**: which of the service's fields an accountant's submission sets is read from the service
before 1608 is built.
File diff suppressed because one or more lines are too long
@@ -1,213 +0,0 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog]
updated: 2026-10-22
decisions:
- "ADR 0515"
- "ADR 0516"
- "ADR 0517"
- "ADR 0518"
- "ADR 0519"
- "ADR 0459"
- "ADR 0440"
- "ADR 0461"
- "ADR 0462"
- "ADR 0472"
- "ADR 0378"
- "ADR 0278"
- "ADR 0359"
- "ADR 0361"
---
# 63 — The mail web UI with mail reading and Owly
**The mail page stops being a list of mail and becomes the operator's list: what needs them today, this week and
later, what is promised, what is good to know and what can be read later, each line written by mail reading from
the mail, never copied from it. Beside it sits Owly, the mail page's assistant, which answers questions about the
mail, writes drafts the operator sends on their Approve, and suggests tidying the mailbox into an approval inbox,
where only the operator's Approve changes anything. Every action of the operator on the page is recorded, so mail
reading and Owly judge better over time. The classical list stays one click away, as All mail.**
(ADR 0515 to ADR 0519, building on design 59 and ADR 0459.)
The page has no name of its own: it is `mesh-mail-web`'s page, the mail page. Its reference is the mockup the
operator picked on 2026-10-22, kept beside this design as [`63-mockup/mail-page.html`](63-mockup/mail-page.html), a
direction and not a pixel specification; its mail is invented, and no name, address, employer, domain, provider
or amount in it is the operator's.
## 1. What it builds on
Design [59](59-the-mail-web-client.md) delivered the mail web client (its milestone 1) and mail reading (its
milestone 2): `mesh-mail-web` renders the mail page on the server, and `mesh-mail-reader` reads each new mail into
a mail entry, sends a mail signal for the mail that matters and keeps the operator's corrections as reading
lessons. What runs today is the as-is [18](../00-as-is/18-the-mail-modules.md). The operator judged that page
unusable on 2026-10-22: it shows mail groups over a classical list, so the operator still reads each mail to know
what it asks.
This design replaces design 59's milestone 4 (the mail conversation on a thread) and its page of mail groups:
Owly answers before threads exist (ADR 0518) and moves onto a thread once they do, and the page opens on the
operator's list (ADR 0515 §5). It is a design of its own, not an amendment of design 59, by the operator's choice
on 2026-10-22: design 59 is the mail web client and mail reading as first built and closes with its own last steps
(test 13 live, issue 1418, and Betterbird's removal, issue 806); this one is the page rebuilt from a visual design
(playbook [10](../../00-META/process/10-visual-design.md)), in six milestones of its own. Design 59 says where it is
replaced.
## 2. The operator's list (ADR 0515, ADR 0516)
- **Mail reading notes what each mail asks.** A mail entry also holds at most 3 tasks (what to do, and by when),
at most 3 promises (*you promised*, *waiting on others*, *on its way*, with who and when) and whether the mail is
*read later*. Each line is the model's own sentence, at most 120 characters, and never quotes the mail. A batch
holds at most 6 mails, so its larger answer stays within 40 s (ADR 0516).
- **The page opens on the operator's list.** A greeting says what mail signals told the operator today; then
*today*, *this week* and *later* (tasks by their due date), *you promised*, *waiting on others*, *on its way*,
*good to know*, *read later*, *handled today* with undo, and the scam warnings in their own place (ADR 0515 §1).
- **Modes choose accounts.** *Work*, *Personal* and *Everything* each show the accounts `mesh-mail-web`'s setting
`modes` names (ADR 0515 §4).
- **Phone first.** One column on a phone; a wide screen adds the reading pane, and Owly beside the operator's list (ADR 0515
§2, §3).
- **All mail is the classical list.** Accounts, folders, search and one mail stay a view the operator can always
open; a correction of mail reading is made from the reading pane (ADR 0515 §5).
- **One script and one stylesheet from its own origin.** The page stays rendered on the server; the script makes
handling, undo and Owly work without a reload and talks only to the page's own origin. A mail's body stays
escaped text, and an attachment stays a download until milestone 2's decision (ADR 0515 §6).
- **The operator's list starts full.** After the delivery, mail reading reads the last 14 days of inbox mail once more
(ADR 0516 §5).
## 3. Handling and the action log (ADR 0517)
- **Handled is the operator's.** Ticking a task, a promise or a mail handled hides it under *handled today*, with
an undo until the day ends; *remind me tomorrow* hides it until the next day. Nothing in the mailbox changes.
- **Every action is a line in the action log:** handled, undone, reminded, opened, corrected, asked, approved or
*not now*, the mode chosen, and the page opened (*last looked*, which gives *new since you last looked*). A line
holds the act, the mail by its id, the mode and when, and a question's own words; never mail text. Kept a year.
- **The log teaches.** Per sender over 90 days, the counts of mails, opened, handled unopened, reminded and
corrected reach each batch and each question in a part of their own, at most 4 KiB (milestone 5).
- **The verbs.** `mail-reading` gains `list`, `handle`, `unhandle`, `remind`, `record` and `actions`; the page
calls them for the signed-in operator and keeps no state of its own.
## 4. Owly answers (ADR 0518)
- **`ask` on `mail-reading`.** A question (at most 2 KiB) is answered in two judgements: the first chooses up to 5
mails and 3 search terms from the mail entries; mail reading calls `mail_search` and `mail_read`; the second
answers from those mails and names only mails it was given. A question the mails do not answer is answered so.
- **On another node.** Owly's judgements run on the node `ask-node` names (the workstation by default), never on
the node of the batches and the issue tracker, so ADR 0459 §15's spacing holds unchanged. An answer takes up to
about 90 s, 130 s behind the morning briefing; the page shows how long Owly has been waiting.
- **The morning briefing** is one judgement a day from the last 7 days' mail entries, read aloud by the browser's
own speech on the operator's click.
- **Nothing of the conversation is kept** but the question, in the action log. A follow-up is answered fresh until
Owly moves onto a thread (issue 880, then issue 1419 for the thread's caller).
- **On the page:** Owly is a panel beside the operator's list on a wide screen and a sheet on a phone, with suggestion
chips for the questions the operator's list makes likely.
## 5. Owly writes (milestone 4)
**Proposed scope, not yet decided.** This section is what the mockup shows; its decision is written in
milestone 4's prepare step, before any of it is built, and the section is then rewritten to it.
Owly offers three short reply options for a mail that asks a question, a draft in the mail's own language, a new
mail from a sentence ("mail Lena that I'll pick the photos tomorrow"), a nudge for a promise the operator waits
on, and an answer to an invitation. Every one is the operator's to change, and goes out only through `mail_send`
on the operator's Approve on Telegram (ADR 0440 §6, ADR 0361 §2). Drafting is outside every decision so far (ADR
0462 §7 leaves it to issue 1259), and a reply needs `In-Reply-To` and cc (issue 1257): the decision is written in
milestone 4's prepare step, tracked by issue 1864.
## 6. Owly learns and tidies (ADR 0519, ADR 0517 §6)
- **Suggested actions.** Mail reading may suggest *archive*, *report as phishing* or *unsubscribe*, with why; it
carries none out. Batches suggest from scam warnings and from senders the operator never opens; Owly suggests
when asked in words.
- **The approval inbox** on the page shows each, with *Approve* and *Not now*. The signed-in operator's Approve is
the decision; `mesh-mail-web` then calls `mail-store`'s new verb. *Not now* keeps a sender quiet for 30 days.
- **mail-store's writes:** `archive`, `report`, `restore` (the same day's undo) and `unsubscribe`, by the mail's own
one-click way (RFC 8058, to a public address only) or as a mail send on the operator's Approve. `mesh-mail`
answers them only for the mail web client, which calls them only from the page's Approve and undo forms, so nothing
else on the mesh can change the mailbox; an undo writes an *undone* line through `decide`. Flag, delete and draft stay with issue 730.
- **The action log's counts reach the batches** and Owly's questions, in a part of their own (ADR 0517 §6 to §8).
- **Teaching in the chat (proposed scope, not yet decided).** "Stop telling me about Strava" becomes a reading
lesson, shown under *Owly learned* with an undo. Whether a lesson taught in words needs more than ADR 0459 §8
decides is settled in milestone 5's prepare step, tracked by issue 1866, before it is built. *Remind me tomorrow*
and *read later* are milestone 1's (ADR 0516, ADR 0517).
## 7. Fast on the desktop (milestone 6)
**Proposed scope, not yet decided:** its decision is written in milestone 6's prepare step, tracked by issue
1867, before it is built.
Keyboard shortcuts (`j` and `k` move, `x` ticks, `r` opens the reply options, `/` jumps to Owly, `?` lists them)
and bulk actions (tick several, handle or archive them at once, each archive still on the approval inbox's
Approve). Tracked by issue 1867.
## 8. What stays outside
- **A calendar.** "Put the viewing in my calendar" needs a calendar on the mesh; none exists, so the page shows the
date and offers nothing more. No issue is opened until a calendar is decided.
- **Attachments shown in place** change ADR 0440 §5 (an attachment is only a download); milestone 2 writes that
decision before it builds (issue 1861).
- **The mail conversation on a thread** waits for threads (issue 880) and its caller (issue 1419); ADR 0462 then
decides again for Owly.
- **The work account** stays a later decision of its own (ADR 0359 §7).
## 9. The build's acceptance tests
1. Each new mail's entry holds its tasks, promises and read-later mark; every line is at most 120 characters and
shares no run of 8 words with the mail's text; a batch holds at most 6 mails and its question stays under 64
KiB (ADR 0516 §1 to §3).
2. A correction removing a task or a promise, or changing the read-later mark, is kept as a reading lesson and
followed by the next batch (ADR 0516 §4).
3. After the delivery the last 14 days of inbox mail are read once, and a restart does not read them again
(ADR 0516 §5).
4. The mail page opens on the operator's list: the greeting with today's mail signals, the sections in their order, scam
warnings in their own place, the modes from `modes`, and *All mail* opening the classical list (ADR 0515).
5. Handling a task moves it to *handled today*, undo returns it, remind hides it until the next day, and each act
leaves one line in `actions` with no mail text; a line older than 365 days is removed (ADR 0517).
6. Every page's content security policy allows script and style from its own origin only, no inline script, and
`connect-src 'self'`; a mail body with `<script>` and a remote image is shown as text (ADR 0515 §6).
7. Tests 1, 2, 4 and 5 run in one integration test that starts `mesh-mail-reader` and `mesh-mail-web` whole
against the real bus and store, with a fixed judgement (issue 1860).
8. Live: the operator uses the operator's list on the phone and on the desktop for a day.
9. (Milestone 2, proposed until its decision.) A photo, a PDF's pages, an invitation, a sheet and a zip's contents open in place, as its decision
says (issue 1861).
10. (Milestone 3.) `ask` answers naming only mails it was given, says so when nothing answers, never judges on the
batches' node, and records the question with no answer text; the morning briefing is one judgement a day from
the entries alone (ADR 0518).
11. (Milestone 3.) Live: the operator asks three questions on the phone and the desktop, and each answer names
real mails.
12. (Milestone 4, proposed until its decision.) A reply option, a draft and a nudge each reach the operator's Approve on Telegram, and nothing
is sent without it (issue 1864).
13. (Milestone 5.) A suggestion appears in the approval inbox; Approve archives the mail, undo restores it,
*Not now* is recorded and not suggested again for 30 days; a one-click unsubscribe refuses a private address;
`mesh-mail-reader` invokes no write verb (ADR 0519).
14. (Milestone 5.) The per-sender counts reach a batch's question in a part of their own of at most 4 KiB, the
batch's senders first, and the reading lessons keep their 12 KiB (ADR 0517 §6); a lesson taught in the chat
changes the next batch and is undone from *Owly learned* (proposed until its decision).
15. (Milestone 6, proposed until its decision.) Each shortcut does what section 7 says, and a bulk handle of five tasks is five lines in the
action log.
## 10. Milestones
| # | Outcome, in one sentence | Proves | Repositories and their parts | Depends on |
|---|---|---|---|---|
| 1 | The operator opens the mail page on the phone or the desktop and sees what needs them, ticks it off, undoes it, and switches between Work and Personal | tests 1 to 8 | mesh-catalog: `mesh-mail-reader`'s tasks, promises and read later (1857), handling and the action log (1858); `mesh-mail-web`'s operator's list (1859, with 1850); the integration test (1860); one entry per message (1849), the health's first state (1580) | — |
| 2 | Photos, PDFs, invitations, sheets and zips open in place on the mail page | test 9 | mesh-catalog: `mesh-mail`'s previews and `mesh-mail-web`'s viewer (1861), after its decision | 1 |
| 3 | The operator asks Owly about their mail and hears a morning briefing | tests 10, 11 | mesh-catalog: `mail_search` made fit (1433); `mail-reading`'s `ask` (1862); Owly on the page (1863) | 1 |
| 4 | Owly writes reply options, drafts, new mail and nudges, each sent on the operator's Approve | test 12 | mesh-catalog: drafting and replies (1864), after its decision | 3 |
| 5 | Owly suggests tidying into an approval inbox, and learns from what the operator teaches and does | tests 13, 14 | mesh-catalog: `mail-store`'s writes and the approval inbox (1865, answering 730 for its four verbs); teaching and the counts (1866) | 3 |
| 6 | On the desktop the operator works the operator's list from the keyboard and handles several items at once | test 15 | mesh-catalog: shortcuts and bulk actions (1867) | 1 |
**Milestones 1 to 6 and their issues, confirmed by the operator on 2026-10-22.** Each milestone is one release of
`mesh-catalog` (playbook [07](../../00-META/process/07-feature-branches.md)), built one at a time, the smallest useful
one first. Their issues, in their order (each depends on the one before unless said):
- Milestone 1: 1857 (tasks, promises, read later), 1858 (handling and the action log; after 1857), 1859 (the operator's list
on the page, with 1850; after 1858), 1860 (the integration test; after 1859); 1849 and 1580 depend on none.
- Milestone 2: 1861 (a decision first, ADR 0440 §5).
- Milestone 3: 1433 (depends on none), 1862 (`ask`; after 1857), 1863 (Owly on the page; after 1862 and 1859).
- Milestone 4: 1864 (a decision first; after 1863).
- Milestone 5: 1865 (after 1863; answers 730 for archive, report, restore and unsubscribe), 1866 (after 1857, 1858 and
1863).
- Milestone 6: 1867 (after 1859).
Issue 1432 (design 59's mail conversation) will not be done: Owly replaces it (ADR 0518). Issue 1419 stays open
for the move onto a thread, which ADR 0518 leaves to threads; Owly's first version reads as `mesh-mail-reader`
and does not need it.
-5
View File
@@ -58,11 +58,6 @@ document is written and this one's status becomes `implemented`.
| [`56-the-mail-client-set-up-by-the-mesh.md`](56-the-mail-client-set-up-by-the-mesh.md) | **Designed.** On every node with a display server, the `betterbird` module installs Betterbird from a pinned upstream release mirrored into the artifact store, ships the Owl add-on pinned and installed by Betterbird's enterprise policy, and declares the operator's mail accounts in a trusted setting without credentials; each account signs in once at a person's first use, and one the profile already holds is adopted by its address | ADR 0316, ADR 0317 |
| [`57-building-config-and-provisioning.md`](57-building-config-and-provisioning.md) | **Designed.** The work of ADR 0348 and ADR 0349 as 37 packages, each an issue with its proof: foundations, template files, config in its own module, the provisioner and its client, secrets in the vault, bindings from providers, collections, a module's runtime readers, and removing the old forms; at most five worked at once | ADR 0348, ADR 0349 |
| [`58-agent-work-as-the-meshs-own.md`](58-agent-work-as-the-meshs-own.md) | **Designed.** Agent work as the mesh's own: a thread seat keeps every thread whole on the bus; a thread handler runs the harness headless in a sandbox per session, the workspace on the thread's own forge branch; a workspace toolkit and named tools, no general command runner; one identity seat for persons, agents and modules, roles of grants, every tool an effect, signed bus logins, and the switch to them one identity at a time | ADR 0383, ADR 0384, ADR 0385, ADR 0386, ADR 0387, ADR 0388, ADR 0389, ADR 0390 |
| [`59-the-mail-web-client.md`](59-the-mail-web-client.md) | **In progress.** The operator's mail, every account, in a browser on the mesh's own machines: a module of its own, `mesh-mail-web`, holds `mail-webclient`, renders pages on the server and reads and sends only through `mail-store`'s verbs under a caller granted per account; its route reaches the private network only, the operator signs in with Keycloak, and a mail goes out only on the operator's Approve; then Betterbird is removed | ADR 0440, ADR 0378, ADR 0379 |
| [`60-the-brother-module.md`](60-the-brother-module.md) | **In progress.** One module, `brother`, holds the `scanner` and `printer` seats for the Brother multifunction device: scans from the glass (cropped) or the feeder (one PDF), keeps each scan in its bucket and announces it, prints in one call, reads the admin pages and changes settings on those where a mistake cannot lock the operator out, each change recorded and told; only its device process holds the credentials | ADR 0467, ADR 0464, ADR 0468, ADR 0474 |
| [`61-the-print-and-scan-page.md`](61-the-print-and-scan-page.md) | **Designed.** A web page, behind a Keycloak sign-in and on a public name, where the people the operator names print a PDF or a photo and scan the glass, and only managers manage the printer: a module of its own, `print-and-scan`, that calls only the printer, scanner and object-store seats, so any holder of those seats works with it; documents go from the browser to the page and by signed links to the store, never over the bus | ADR 0479, ADR 0468, ADR 0440 |
| [`62-the-bookkeeping-portal.md`](62-the-bookkeeping-portal.md) | **Proposed.** A web page on the private network, behind the operator's Keycloak sign-in, showing the business's income, expenses and to-do as the bookkeeping service holds them now; `okioki` moves whole to the home server and the page `okioki-web` runs beside it, calling only the `bookkeeping` seat; nothing the accountant reviewed or submitted is ever changed | ADR 0481, ADR 0440, ADR 0479 |
| [`63-the-mail-web-ui-with-mail-reading-and-owly.md`](63-the-mail-web-ui-with-mail-reading-and-owly.md) | **In progress.** The mail page becomes the operator's list: what needs them today, this week and later, what is promised, what is good to know and what can be read later, written by mail reading; Owly beside it answers, writes drafts sent on the operator's Approve, and suggests tidying into an approval inbox; every action on the page is recorded so reading judges better; in six milestones | ADR 0515, ADR 0516, ADR 0517, ADR 0518, ADR 0519 |
## Not yet written
@@ -61,7 +61,7 @@ On 2026-10-09 the operator took the rename as long implemented and live. It is n
2. **The rule**, from 2026-10-09: a decision that defers work names the issue that tracks it, with an
owner, or the deferral is not accepted. Playbooks [02](../../00-META/process/02-graduation.md)
(graduation) and [04](../../00-META/process/04-build-handoff.md) (handoff) say it;
the cycle test (`cycle.py` then) checks it: an accepted record dated from 2026-10-09, or an
[`cycle.py`](../../00-META/checks/cycle.py) checks it: an accepted record dated from 2026-10-09, or an
older one corrected under the rule, whose text puts work off ("until renamed", "left for later", "not
decided here", "postponed", "future work", "deferred", "a later change") names the issue as tracking it
("tracked by issue N"), in that paragraph or list item, or in a dated progressive insight in the same
+6 -13
View File
@@ -22,9 +22,8 @@ Work enters as an **idea** (playbook [01 — research](00-META/process/01-resear
[04](00-META/process/04-build-handoff.md), on a feature branch per playbook
[07](00-META/process/07-feature-branches.md)) — and on shipping the as-is is updated and the
design flips to `implemented`. **No design without a decision; no development without a design
that names its owner.** Enforced by the cycle test in
[`00-META/checks/`](00-META/checks/) alongside the records, index and words tests — run `go test ./...` at the
root before any merge here.
that names its owner.** Enforced by [`00-META/checks/cycle.py`](00-META/checks/cycle.py)
alongside `records.py`, `index.py` and `words.py` — run all four before any merge here.
## Where to look (before assuming anything)
@@ -50,19 +49,13 @@ One name per thing. [`00-META/glossary.md`](00-META/glossary.md) is the authorit
vocabulary, organised by the mesh's domains (ADR 0244):
*controller* (not "control plane"), *foundation* (not "substrate"), *node-engine* (not "the host"), a
*node* is a machine the mesh has adopted, *seat* / *bench* / *claim*, *package* vs *artifact*. Use those
words. A retired word is named on its replacement's *Not:* line, and the words test in `00-META/checks/` fails on it
words. A retired word is named on its replacement's *Not:* line, and `00-META/checks/words.py` fails on it
in running prose; a quotation keeps its words.
## Ground rules
- **Markdown only** — but for the four checks, Go tests in the package `00-META/checks/` with `go.mod` at the
root and no other Go code (ADR 0527), which the checker runs on every pull request as `mesh/check`; and
`00-META/process/playbooks.html`, a drawn overview of the playbooks kept for a later docs site (the operator,
2026-10-19); a change to a playbook's steps updates it in the same pull request. Nothing checks that
yet: issue 1222 asks for the check.
A to-be design may also keep its reference mockup, the static HTML page the operator picked, in a
folder beside it (`NN-mockup/`, playbook [10](00-META/process/10-visual-design.md); the operator,
2026-10-22), its data invented: no real names, addresses, domains, providers or amounts.
- **Markdown only** — but for the checks in `00-META/checks/` and `merge-check.sh` at the root, which
runs them on every pull request as its `mesh/repo-check` (ADR 0238).
No new top-level folders without explicit confirmation.
- **Status lives in YAML frontmatter** — on design docs (`layer`, `status`, `code`, `updated`,
and `decisions` as "ADR NNNN"). A decision record's and a research effort's status live in the knowledge
@@ -71,7 +64,7 @@ in running prose; a quotation keeps its words.
Never create a central status file; cross-cutting views are generated when they are read.
- **No decision record or research effort is written here.** They live whole in the knowledge base and
are written with `knowledge-base.reserve` and `knowledge-base.propose` (playbooks
[01](00-META/process/01-research.md) and [02](00-META/process/02-graduation.md)); the records test refuses a
[01](00-META/process/01-research.md) and [02](00-META/process/02-graduation.md)); `records.py` refuses a
new path under `02-DECISIONS/` or `01-RESEARCH/`. A record is cited as "ADR NNNN" or "research NNN".
- **Decision records hold their meaning.** Supersede with a new record rather than rewriting
what was decided, the options weighed, or a consequence another record relies on. A **progressive
+1 -1
View File
@@ -40,7 +40,7 @@ Implementation lives in the code repositories — see
## Rules
- Markdown only, but for the checks (Go tests, `go test ./...` at the root, run by the checker as `mesh/check`) and one drawn overview of the playbooks (`00-META/process/playbooks.html`).
- Markdown only.
- No new top-level folders without explicit confirmation.
- Knowledge flows `GENESIS → RESEARCH → DESIGN`. Research graduates into design only after
analysis against GENESIS confirms alignment, and only through a recorded decision.
-3
View File
@@ -1,3 +0,0 @@
module github.com/novox/hq
go 1.26.0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# mesh-check-toolchain: go
#
# This repository's own check (ADR 0238): the second layer of a pull request's merge check,
# `mesh/repo-check`, run by the build seat in the mesh's Go toolchain, which carries Python for it. No
# module is built from here, so no gate runs (`mesh/merge-gate` says the change touches none). The four
# checks every merge here must pass (AGENTS.md): the documents' structure (and that no decision record or
# research effort is written here: they live in the knowledge base, ADR 0290), the reading order, the cycle,
# and the words (ADR 0244: no retired word in running prose, no word defined twice).
set -eu
python3 00-META/checks/records.py
python3 00-META/checks/index.py
python3 00-META/checks/cycle.py
python3 00-META/checks/words.py