Compare commits
296
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94d52534ab | ||
|
|
6808125e1d | ||
|
|
32da464794 | ||
|
|
10bc8d396c | ||
|
|
8e732e22ae | ||
|
|
6ef40bbcc7 | ||
|
|
7ff4c7ce68 | ||
|
|
0d1aa93e37 | ||
|
|
1afab9739e | ||
|
|
420751b214 | ||
|
|
043f8ecf9c | ||
|
|
132aae8433 | ||
|
|
28d84f31bf | ||
|
|
278ca42b3d | ||
|
|
1dab64134a | ||
|
|
a0e4c1fe17 | ||
|
|
7c88f297fc | ||
|
|
495d740d5f | ||
|
|
df4ff3d039 | ||
|
|
d698942b1d | ||
|
|
a6cc3d2e0b | ||
|
|
b6572acd72 | ||
|
|
ebc1a84e59 | ||
|
|
5bb109e36e | ||
|
|
d948774794 | ||
|
|
68f8a8c198 | ||
|
|
b5b84fafc6 | ||
|
|
0488bc1801 | ||
|
|
973182cdf9 | ||
|
|
97d600650a | ||
|
|
515c405c89 | ||
|
|
a6e27687db | ||
|
|
b7dabd8ceb | ||
|
|
3a5c4c7c4d | ||
|
|
250c3ff605 | ||
|
|
a3f6e967c0 | ||
|
|
11b0a450c3 | ||
|
|
122cda84b3 | ||
|
|
be1bbe0a65 | ||
|
|
585551722a | ||
|
|
f1c50d3e01 | ||
|
|
d70791ddee | ||
|
|
5bdeb8db83 | ||
|
|
d94f8a05a8 | ||
|
|
fd0d712f7d | ||
|
|
de4c69d476 | ||
|
|
9eb963d5f1 | ||
|
|
c255fd071c | ||
|
|
b11120f681 | ||
|
|
0354947ba6 | ||
|
|
4d596bd211 | ||
|
|
0e1d0392bc | ||
|
|
ba09497092 | ||
|
|
2d3a205740 | ||
|
|
49dd8fc7e3 | ||
|
|
9558b71a4a | ||
|
|
3dbb5d4e65 | ||
|
|
4b514e4d69 | ||
|
|
8433e42c03 | ||
|
|
129af9906d | ||
|
|
2249d30151 | ||
|
|
c12d503b2e | ||
|
|
8498006d23 | ||
|
|
d20ed892cf | ||
|
|
d08cac4e86 | ||
|
|
ca460b681b | ||
|
|
b3b778a1a6 | ||
|
|
6dc008b5e7 | ||
|
|
82aa3f6afa | ||
|
|
8055006ba1 | ||
|
|
2b53b6a5d2 | ||
|
|
87c338da8e | ||
|
|
656f3af86f | ||
|
|
e8bd9171cd | ||
|
|
8f7c23da7d | ||
|
|
d49dccb8d5 | ||
|
|
6ead37ee7b | ||
|
|
c8ccbfe35f | ||
|
|
99fa8f0f09 | ||
|
|
75056fa931 | ||
|
|
47e78d8ff4 | ||
|
|
1385452dc9 | ||
|
|
5b63a86464 | ||
|
|
344c92498d | ||
|
|
9339e953b8 | ||
|
|
0ad95768b6 | ||
|
|
dd45f31307 | ||
|
|
fa7629d02b | ||
|
|
93963aeb51 | ||
|
|
795b3b5d45 | ||
|
|
030e985694 | ||
|
|
eddd7ba684 | ||
|
|
5d745d04db | ||
|
|
7fcca88c01 | ||
|
|
333e73830c | ||
|
|
58555ec330 | ||
|
|
9a74de0bd6 | ||
|
|
77d4a8a39e | ||
|
|
2b857df4b5 | ||
|
|
73a06324dc | ||
|
|
c07483081d | ||
|
|
1955d7f624 | ||
|
|
57396112c7 | ||
|
|
38020bee9a | ||
|
|
04fff52d3b | ||
|
|
ac72fe6422 | ||
|
|
9483caae2d | ||
|
|
302ab2856a | ||
|
|
a214776696 | ||
|
|
ac7e445e7a | ||
|
|
0162e04f92 | ||
|
|
37a01b93a5 | ||
|
|
c6fc663019 | ||
|
|
5f281dd2a3 | ||
|
|
d5d8212204 | ||
|
|
aa56e9b9e7 | ||
|
|
4f8c469329 | ||
|
|
abdd364f09 | ||
|
|
ad0a95dc0c | ||
|
|
c770bc1c50 | ||
|
|
ab3ac686dc | ||
|
|
373b7e7dd1 | ||
|
|
6eda564701 | ||
|
|
7c8e1f521d | ||
|
|
a1188b64a4 | ||
|
|
6db031f486 | ||
|
|
be30b63976 | ||
|
|
993c7def3a | ||
|
|
a2918df0aa | ||
|
|
46d6840c67 | ||
|
|
b717900700 | ||
|
|
82343be78f | ||
|
|
75f2cbb6d6 | ||
|
|
467a2e6c97 | ||
|
|
115df9fd69 | ||
|
|
c7bebf53d4 | ||
|
|
e0118ca67e | ||
|
|
64e64ac69d | ||
|
|
e111fb875c | ||
|
|
a0ab91dcae | ||
|
|
9f0b0215ae | ||
|
|
86fd4adf97 | ||
|
|
0b21bd0b26 | ||
|
|
e212e3841d | ||
|
|
8d3a9011ac | ||
|
|
fa165392b1 | ||
|
|
b6e01318cf | ||
|
|
80653c078d | ||
|
|
ba9f063c77 | ||
|
|
70eff93d01 | ||
|
|
9a9934d47e | ||
|
|
7f3a3113aa | ||
|
|
34e3321c7f | ||
|
|
2713e131b4 | ||
|
|
def2c09308 | ||
|
|
5705da30c0 | ||
|
|
1d7cfed47a | ||
|
|
a5c6c98ecc | ||
|
|
d2dccddc27 | ||
|
|
dcc6f185d4 | ||
|
|
e925a843ce | ||
|
|
66bb013b85 | ||
|
|
0738fa1405 | ||
|
|
a0a4edabc9 | ||
|
|
ebc7665b2a | ||
|
|
f96f964a2d | ||
|
|
4a2c15f725 | ||
|
|
ae9768c2c1 | ||
|
|
440279fec4 | ||
|
|
3439d19d06 | ||
|
|
89c4295f6d | ||
|
|
3e061bbd99 | ||
|
|
f09cc22d99 | ||
|
|
6ffd3709d8 | ||
|
|
990a77c760 | ||
|
|
cc605e3b13 | ||
|
|
023ad01160 | ||
|
|
9ffa60924e | ||
|
|
c6d36b2f32 | ||
|
|
f6311a66bf | ||
|
|
c04c0ebb76 | ||
|
|
491de157da | ||
|
|
bea864af8b | ||
|
|
7ed83f4965 | ||
|
|
71759e83f8 | ||
|
|
3174d5aa6e | ||
|
|
e7c8ce2740 | ||
|
|
5816ca669f | ||
|
|
afe83b9124 | ||
|
|
f269e83f5d | ||
|
|
6c607504de | ||
|
|
20c857443c | ||
|
|
7735cfd107 | ||
|
|
7e1c0f4c15 | ||
|
|
19a8e92801 | ||
|
|
59c6954b0c | ||
|
|
95bf5cbf20 | ||
|
|
f0bcc75fcf | ||
|
|
550487fc90 | ||
|
|
022392a22b | ||
|
|
643ec2b1bf | ||
|
|
ee862d8e22 | ||
|
|
292fae8863 | ||
|
|
f9139feb30 | ||
|
|
d10965a341 | ||
|
|
f7915f7f29 | ||
|
|
86e66dd51f | ||
|
|
a353ed21c0 | ||
|
|
4a090df25a | ||
|
|
ea7c831196 | ||
|
|
d81c23b65a | ||
|
|
0f8d778ec2 | ||
|
|
cd9336d05a | ||
|
|
5c81f1170c | ||
|
|
ba127e29b7 | ||
|
|
d0a2950bb0 | ||
|
|
8e76098412 | ||
|
|
42ecaa7ac4 | ||
|
|
cec72d1dc9 | ||
|
|
b98b93efc8 | ||
|
|
ec373556d0 | ||
|
|
f242b8763b | ||
|
|
bb6af05f5c | ||
|
|
672011ab92 | ||
|
|
05ff547358 | ||
|
|
1def984c07 | ||
|
|
76a671b8c5 | ||
|
|
19bd7fd960 | ||
|
|
f82a90dfa0 | ||
|
|
17c27ddf83 | ||
|
|
5c6b645c6b | ||
|
|
b6cf49d8ce | ||
|
|
1484ff7682 | ||
|
|
c10efa4d8c | ||
|
|
24fd567c31 | ||
|
|
be5284f7b8 | ||
|
|
21c4d212ec | ||
|
|
1bd6f4611e | ||
|
|
9d59c2ce14 | ||
|
|
64e357da7d | ||
|
|
9a9a78f882 | ||
|
|
e816b61cb7 | ||
|
|
610be93803 | ||
|
|
d5ddd1e715 | ||
|
|
a6db6a70cf | ||
|
|
e44ec3f612 | ||
|
|
dbc7406490 | ||
|
|
2001d6e063 | ||
|
|
90c6793096 | ||
|
|
499407c669 | ||
|
|
2cf1c64e24 | ||
|
|
e1987b3654 | ||
|
|
c87ab9af0f | ||
|
|
4e0dd1b0d3 | ||
|
|
b5d3f23947 | ||
|
|
c753ff068a | ||
|
|
7a1d3d89f0 | ||
|
|
cb79a28a80 | ||
|
|
0b9fda46cc | ||
|
|
6b58a6af82 | ||
|
|
9a39785116 | ||
|
|
c0b1c47be6 | ||
|
|
06440c1522 | ||
|
|
0e7eb9c2da | ||
|
|
cf73f96797 | ||
|
|
b0cfd08f7c | ||
|
|
0a6613f0d4 | ||
|
|
aaa0647ec3 | ||
|
|
de32fb82ea | ||
|
|
85a729c607 | ||
|
|
fb284fa6ee | ||
|
|
2b04a5f0a1 | ||
|
|
addbd74c39 | ||
|
|
6bf18062c4 | ||
|
|
0f63a73def | ||
|
|
d0ee14712e | ||
|
|
f6199fafce | ||
|
|
d901156614 | ||
|
|
e934a0286f | ||
|
|
8dbfd8d8ff | ||
|
|
2cea6c9b0c | ||
|
|
79dcc0a6ec | ||
|
|
65f69d96a9 | ||
|
|
b83a1e748e | ||
|
|
84b464d109 | ||
|
|
88d7db36c8 | ||
|
|
974b3e7d42 | ||
|
|
c61f01d63e | ||
|
|
4325467b4d | ||
|
|
0a010b8b79 | ||
|
|
cc0503ef66 | ||
|
|
56d8d546c7 | ||
|
|
edff1f25a1 | ||
|
|
85f0aecf5a | ||
|
|
0dc8cab178 | ||
|
|
98b731d5d1 |
@@ -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 `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`,
|
||||
live in the tracker (ADR 0275), and the cycle test 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.
|
||||
|
||||
@@ -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. `index.py` fails if the list is stored there again.
|
||||
is why that went too. the index test 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
@@ -15,7 +15,7 @@ works — plus the engineering practice that holds across everything Novox build
|
||||
|
||||
## Rules
|
||||
|
||||
- Markdown only.
|
||||
- Markdown only, but for the checks and one drawn overview of the playbooks (`process/playbooks.html`).
|
||||
- **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.
|
||||
|
||||
+18
-14
@@ -1,15 +1,19 @@
|
||||
# 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).
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Non-zero exit on any problem, so it can be a gate rather than a report.
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -26,7 +30,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.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 |
|
||||
| *(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 |
|
||||
| `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
|
||||
@@ -37,7 +41,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 | Where it lives now |
|
||||
| Rule here until issue 426 (the Python checks, deleted with issue 1296) | 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 |
|
||||
@@ -47,7 +51,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 `index.py` still checks |
|
||||
| `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` `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` |
|
||||
@@ -72,7 +76,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.
|
||||
|
||||
## cycle.py
|
||||
## The cycle test
|
||||
|
||||
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
|
||||
@@ -84,9 +88,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). `python3 00-META/checks/cycle.py`
|
||||
with them (the table above).
|
||||
|
||||
## words.py
|
||||
## The words test
|
||||
|
||||
One word per thing, checked (ADR 0244).
|
||||
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
|
||||
@@ -101,7 +105,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
|
||||
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
|
||||
(`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
|
||||
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.
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,169 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,93 @@
|
||||
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)}))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read the repository's documents (rootFS), outside this directory: mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Words allowed — documents `words.py` does not hold to the glossary's retired words
|
||||
# Words allowed — documents the words test does not hold to the glossary's retired words
|
||||
|
||||
Read by [`words.py`](words.py) (ADR 0244).
|
||||
Read by the words test (`words_test.go`) (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,3 +8,15 @@ 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 |
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One word per thing, checked (ADR 0244).
|
||||
|
||||
The glossary (00-META/glossary.md) is the authority on the mesh's words. It names the words it
|
||||
retired on one fixed kind of line, so a program can read them:
|
||||
|
||||
*Not:* ~~control plane~~, ~~overlay~~ (hq)
|
||||
|
||||
A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only;
|
||||
`(tools)` in the descriptions of the mesh's tools only. And it names code that still carries an old
|
||||
name until that code is renamed:
|
||||
|
||||
*Identifier until renamed:* `mesh-host` — the repository, binary and service unit
|
||||
|
||||
An identifier is allowed inside a code span and nowhere else.
|
||||
|
||||
What is enforced:
|
||||
|
||||
retired no retired word of scope `hq` (or none), and no identifier, in running prose of a
|
||||
checked document. Running prose is what is left once code blocks, code spans, block
|
||||
quotes, text in quotation marks, struck-through text, link targets, HTML comments and
|
||||
frontmatter are taken out: a quotation keeps the words it quotes, a link target is a file
|
||||
name, and an identifier lives in a code span. A word's plural is the word: "alerts" fails
|
||||
as "alert" does.
|
||||
unique no head word (a bolded word opening a glossary entry) heads two entries, and no head
|
||||
word is also struck through on a *Not:* line.
|
||||
tools list with `--list tools` it prints the words retired in the tools' descriptions, which is the
|
||||
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
|
||||
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
|
||||
|
||||
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
|
||||
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
|
||||
controller (`mesh-controller`) are daemons, and a person never types their names — so a
|
||||
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
|
||||
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
|
||||
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
|
||||
module check …` is a line). A line is looked for in the document's own words — code
|
||||
blocks and code spans included, since that is where a line to type is written — with
|
||||
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
|
||||
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
|
||||
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
|
||||
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
|
||||
typed at a machine no mesh reaches yet, and the engine's usage says so.
|
||||
|
||||
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; 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))
|
||||
@@ -0,0 +1,756 @@
|
||||
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
|
||||
}
|
||||
+311
-94
@@ -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 `00-META/checks/words.py` reads them:
|
||||
Two fixed lines follow an entry where they apply, and the words test in `00-META/checks/` 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 `words.py`.
|
||||
checked by review, not by the words test.
|
||||
- **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,6 +98,9 @@ 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).
|
||||
@@ -170,8 +173,10 @@ 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 the controller declares: it receives
|
||||
the node's declaration, writes the files, runs the services and containers, and reports what it did.
|
||||
- **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.
|
||||
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:
|
||||
@@ -224,18 +229,30 @@ 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 (Change and delivery) is
|
||||
text is about the controller's records; the builder's *mirrored* `upstream` image (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. **Upstream of** Provisioning, Connectivity, Data
|
||||
and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 0252, 0261.
|
||||
**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.
|
||||
**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).
|
||||
@@ -256,23 +273,42 @@ 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** — 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).
|
||||
- **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).
|
||||
Not a name a manifest carries.
|
||||
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
|
||||
stated on the node record at the controller's terminal, never by a verb or a 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).
|
||||
- **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).
|
||||
- **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
|
||||
@@ -340,8 +376,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, Change and delivery, and Operator and conversation.
|
||||
**Decided by** ADR 0085, 0113, 0183, 0225, 0234, 0386, 0387, 0388.
|
||||
**Upstream of** Provisioning, Delivery, and Operator and conversation.
|
||||
**Decided by** ADR 0085, 0113, 0183, 0225, 0234, 0386, 0387, 0388, 0411, 0413, 0414, 0550.
|
||||
**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**
|
||||
@@ -353,10 +389,16 @@ 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)).
|
||||
[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).
|
||||
- **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. 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).
|
||||
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).
|
||||
- **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
|
||||
@@ -389,34 +431,62 @@ 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).
|
||||
|
||||
## Change and delivery — a commit on its way to the nodes
|
||||
## Delivery — a commit on its way to the nodes
|
||||
|
||||
**Purpose.** To take one commit from its pull request to every node that should run it, and to put it
|
||||
back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the
|
||||
planner, the 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.
|
||||
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.
|
||||
**Uses:** module, node, declaration and send (Placement), health (Health and repair), person.
|
||||
|
||||
- **merge check** — the two statuses a pull request carries before it may merge: the **merge gate**
|
||||
(`mesh/merge-gate`, the build seat's composed check of the modules the change touches) and the
|
||||
**repository check** (`mesh/repo-check`, the repository's own `merge-check.sh`)
|
||||
(ADR 0238). 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).
|
||||
- **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).
|
||||
- **trunk commit** — the commit of the trunk a merge check's verdict was judged against: the trunk's head when
|
||||
the build seat cloned it. The verdict holds for that commit only, and goes out of date when the trunk moves
|
||||
(ADR 0323).
|
||||
- **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the
|
||||
**builder**. Any node may hold the seat, so there is no build node by nature
|
||||
(ADR 0237).
|
||||
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).
|
||||
*Not:* ~~build machine~~
|
||||
- **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)).
|
||||
- **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**.
|
||||
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by **version**.
|
||||
Served by the **package-registry** (gitea). Only a builder talks to it.
|
||||
Served by the **package-registry** (gitea). Only a build module 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
|
||||
@@ -443,14 +513,17 @@ planner, the rollout). **Downstream of** Module, Core, Health and repair and Dat
|
||||
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). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy
|
||||
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
|
||||
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).
|
||||
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).
|
||||
- **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.
|
||||
@@ -460,32 +533,78 @@ planner, the rollout). **Downstream of** Module, Core, Health and repair and Dat
|
||||
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
|
||||
the build seat, pinned to one version that is the same on every builder; its version is part of a build's reuse
|
||||
its build seat, pinned to one version that is the same on every build node; 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` shows it. *Deploy* is used in this one place.
|
||||
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).
|
||||
*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).
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
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.
|
||||
- **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.
|
||||
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.
|
||||
*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). Not *the gate* bare (see [Homonyms](#homonyms)).
|
||||
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)).
|
||||
- **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
|
||||
@@ -501,8 +620,9 @@ planner, the rollout). **Downstream of** Module, Core, Health and repair and Dat
|
||||
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`. Used in
|
||||
this sense only.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -512,7 +632,7 @@ planner, the rollout). **Downstream of** Module, Core, Health and repair and Dat
|
||||
|
||||
**Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended,
|
||||
and record what was done by hand. **Recorded by** the controller's condition store.
|
||||
**Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition
|
||||
**Upstream of** 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).
|
||||
|
||||
@@ -554,7 +674,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** Change and delivery and Health and repair. **Decided by** ADR 0232, 0233, 0235.
|
||||
**Upstream of** 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`,
|
||||
@@ -601,10 +721,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, Change and delivery, and Identity and access. An authorising
|
||||
**Downstream of** Health and repair, 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 (Change and delivery).
|
||||
**Uses:** operator, person, node, module, seat, tool (Module), proof and identity (Identity and access), release (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`,
|
||||
@@ -618,10 +738,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*; 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
|
||||
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
|
||||
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
|
||||
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
|
||||
or the node-engine's binary followed by a command), which the words test 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`
|
||||
@@ -660,8 +780,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 the
|
||||
build seat's holder, whose seat name `node-build-agent` is an identifier to rename.
|
||||
(ADR 0386). Not the node-engine, and not a
|
||||
build module.
|
||||
- **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).
|
||||
@@ -720,11 +840,80 @@ 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.
|
||||
0284, 0290, 0291, 0423, 0424.
|
||||
|
||||
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation, held whole in the
|
||||
knowledge base and named "research NNN";
|
||||
@@ -738,14 +927,27 @@ 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), takes them as proposals, has them reviewed and accepted,
|
||||
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,
|
||||
and answers what holds: the seat `knowledge-base`, held by the module `mesh-knowledge`
|
||||
([55 — The knowledge base](../03-DESIGN/01-to-be/55-the-knowledge-base.md)). Not **the record**, the literal reader
|
||||
of this repository.
|
||||
- **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built).
|
||||
- **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).
|
||||
- **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)
|
||||
- **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.
|
||||
@@ -775,34 +977,39 @@ 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 `00-META/checks/`, run by `merge-check.sh` as this repository's check.
|
||||
- **hq check** — one of the Go tests in `00-META/checks/`, run by `go test ./...` and by the checker as `mesh/check`.
|
||||
|
||||
## Homonyms
|
||||
|
||||
A word below means different things in different domains. In a governing document it is never bare;
|
||||
it is always the qualified form. **Checked by review, not by `words.py`:** a word list cannot tell one
|
||||
it is always the qualified form. **Checked by review, not by the words test:** a word list cannot tell one
|
||||
sense from another, so the reviewer looks for the bare word in the diff. Where a homonym is settled by
|
||||
renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|
||||
|
||||
| Word | Sense | Say | Domain |
|
||||
|---|---|---|---|
|
||||
| plan | what the controller would send one node | that node's **declaration** (verb `plan`) | Placement |
|
||||
| | what a delivery would do to the mesh | **delivery plan** | Change and delivery |
|
||||
| | the sending of one batch of commits across nodes | **rollout** (verb `plans`) | Change and delivery |
|
||||
| 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 |
|
||||
| | 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 | **send** (verb `push`) | Placement |
|
||||
| push | the controller giving nodes their declarations, until release 6 | **send** (verb `push`) | Placement |
|
||||
| | a git push | **git push** | The record |
|
||||
| | a phone notification | **push notification** | Operator and conversation |
|
||||
| release | a person letting a held delivery go on | **release** | Change and delivery |
|
||||
| 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 |
|
||||
| | taking a consumer off a licence | **unbind** (verb `release`, to rename) | Identity and access |
|
||||
| | a copy of a work an engine can fetch | **media release** | Media |
|
||||
| gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery |
|
||||
| | the pull request status | **merge gate** | Change and delivery |
|
||||
| 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 |
|
||||
| | 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** | Change and delivery |
|
||||
| | a step of a rollout | **tier** | 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 |
|
||||
@@ -810,44 +1017,54 @@ 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** | Change and delivery |
|
||||
| | an entry in the build queue | **build request** (an ask on a build stream) | Delivery |
|
||||
| store | the one database server | **store** | Core |
|
||||
| | the OCI registry | **artifact store** | Change and delivery |
|
||||
| | the OCI registry | **artifact store** | 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 | **merge check**, **merge gate**, **repository check** | Change and delivery |
|
||||
| | a delivery group's verdict | **composed check** | Change and delivery |
|
||||
| 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 |
|
||||
| | the controller's own examination | **self-check** | Health and repair |
|
||||
| | a file in `00-META/checks/` | **hq check** | The record |
|
||||
| agent | a coding agent | **agent** | Operator and conversation |
|
||||
| | the build seat's holder | **builder** | Change and delivery |
|
||||
| | the 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 user | an account of a wrapped program, or of the bus server | **its account**, **bus account** | Identity and access |
|
||||
| | a human | **operator** or **person** | words every domain uses |
|
||||
| firewall | what the mesh enforces | **packet filter** | Connectivity |
|
||||
| | what was found on a machine | **found firewall** | Connectivity |
|
||||
| ticket | an answer's one-time token (ADR 0258) | **answer ticket** | Operator and conversation |
|
||||
| | a question someone must answer | **issue** | The record |
|
||||
| deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery |
|
||||
| deploy | the per-node part of a delivery plan | **deploy plan** | 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 `words.py` then fails on it in running prose. A
|
||||
on a *Not:* line and nowhere else on this page, and the words test 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):
|
||||
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
|
||||
The words test in `00-META/checks/` (`go test ./...`, run by the checker as `mesh/check` 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)*
|
||||
|
||||
@@ -173,6 +173,22 @@ 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):
|
||||
|
||||
@@ -48,6 +48,9 @@ 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 |
|
||||
@@ -57,17 +60,18 @@ the expensive half.
|
||||
| [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 push, 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 |
|
||||
| [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:
|
||||
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
|
||||
the cycle test in [`00-META/checks/`](../checks/) 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 `records.py`, `index.py` and
|
||||
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). The issue rules the check
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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: `records.py` refuses a new path under
|
||||
- Do not write an effort, or a copy of one, into this repository: the records test 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 `records.py`'s `no-records` check, on every pull request. An active effort with no new version
|
||||
no effort is the records test'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.
|
||||
|
||||
@@ -94,7 +94,10 @@ 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`;
|
||||
at most three rounds, then the findings go to the operator with a recommendation.
|
||||
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.
|
||||
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.
|
||||
@@ -150,7 +153,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: `records.py` refuses a new path
|
||||
- Do not write a decision record, or a copy of one, into this repository: the records test refuses a new path
|
||||
under `02-DECISIONS/` other than its README.
|
||||
|
||||
## How it is checked
|
||||
@@ -159,8 +162,10 @@ 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, `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
|
||||
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
|
||||
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*.
|
||||
|
||||
@@ -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**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
|
||||
report. **No status key**: the cycle test 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 `cycle.py` refuses two records
|
||||
another (one numbered before the tracker, or by hand) is a fault, and the cycle test 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). `cycle.py` checks what
|
||||
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). the cycle test 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.
|
||||
|
||||
@@ -9,11 +9,19 @@ 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 its delivery plan is done, the operator or the mesh can do
|
||||
- **A milestone is usable on its own.** Once it is delivered, 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 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.
|
||||
- **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).
|
||||
- **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`.
|
||||
|
||||
@@ -30,8 +38,9 @@ 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 plan its changes would
|
||||
have, asked through `mesh-delivery.what-if`: the modules it moves and their fan-out (ADR 0401 §6).
|
||||
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`).
|
||||
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
|
||||
@@ -40,8 +49,10 @@ 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 the feature step; its issues are the
|
||||
step's issues; the next milestone starts when the previous one's delivery plan is done.
|
||||
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.
|
||||
9. **On completion of the last milestone**, run the "when something ships" section of playbook
|
||||
[02](02-graduation.md).
|
||||
|
||||
@@ -52,16 +63,17 @@ 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 ready mark and the delivery — is playbook
|
||||
- How the building is done — worktrees, agents, review, checks, the Approve 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 in hq's merge-check is tracked by issue 845.
|
||||
Read by the lead and the reviewer today; a check among hq's Go tests 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` has had its delivery plan done; its outcome sentence and
|
||||
- A milestone whose issues are all `resolved` is delivered (the status view said `delivered` for its source
|
||||
revision); its outcome sentence and
|
||||
acceptance tests are proven on the mesh before the next milestone starts.
|
||||
|
||||
@@ -105,3 +105,23 @@ 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.
|
||||
|
||||
@@ -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 plan
|
||||
- **Work that waits and nobody notices.** Most lost time was not work at all, but a check, a delivery
|
||||
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,73 +28,199 @@ 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).
|
||||
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.
|
||||
- **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. It runs in five phases, and its time is bounded:
|
||||
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:
|
||||
|
||||
| Phase | What happens | Bound |
|
||||
| Step | 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, 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 |
|
||||
| 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` |
|
||||
|
||||
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 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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## Phase 0 — Prepare (the lead, larger model)
|
||||
**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.
|
||||
|
||||
1. **Name the feature once:** `feat/<slug>`, the same branch name in every repository it touches. The
|
||||
branch is never protected.
|
||||
**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`).
|
||||
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.
|
||||
`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.
|
||||
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. A fixing agent that meets an undecided design question
|
||||
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
|
||||
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), the test to write first, done-when in one sentence;
|
||||
with (quoted, with where each was decided), and its acceptance criteria (item 5), each with the test that
|
||||
proves it;
|
||||
- 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 forge's checks run).
|
||||
integration tests, which the pull request's one check runs).
|
||||
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. **Move the issues to `fixing`**, `by` naming the feature's agent for that repository.
|
||||
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.
|
||||
|
||||
## Phase 1 — Develop (one fixing agent per repository, smaller model)
|
||||
## Step 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, 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.
|
||||
- **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.
|
||||
- **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 write to a store's database by hand, a schema change
|
||||
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
|
||||
is a numbered migration and the consolidated schema; Go only unless the context file says otherwise; never
|
||||
move another issue. Report back in under 150 words: commits, tests added and that they failed first, the
|
||||
check results, anything you stopped on.
|
||||
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.
|
||||
- **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
|
||||
@@ -105,68 +231,245 @@ never into the running one, unless it blocks the running one.
|
||||
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`.
|
||||
|
||||
## Phase 2 — Review (one reviewer, larger model; at most 3 rounds, about 15 minutes)
|
||||
## Step 2 — Review (one reviewer, larger model; at most 3 rounds, about 15 minutes)
|
||||
|
||||
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.
|
||||
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).
|
||||
2. **One reviewer reads every repository's local commits** together, against the context files and the
|
||||
decisions. Its report is per repository: each finding as file:line, the failure it causes, and the fix.
|
||||
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.
|
||||
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. **The reviewer reads the answers and the new commits**, and approves or reports again (step 5 says what a round
|
||||
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
|
||||
may ask). After round 3 the lead stops and brings the operator what is left, with a recommendation.
|
||||
5. **A finding outside the issues' done-when is never a change asked, in any round.** More hardening, a
|
||||
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
|
||||
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 and its decisions.
|
||||
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.
|
||||
|
||||
## Phase 3 — Check (no review)
|
||||
## Step 3 — Check (no review)
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
carries an integration test that starts the module whole, its process against the real bus and store, and
|
||||
shows the step's acceptance sentence; the forge's checks run it (ADR 0391 §1, §6). Unit tests alone are not
|
||||
shows the milestone'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).
|
||||
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.
|
||||
**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.
|
||||
|
||||
**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. Plan such a feature as two steps.
|
||||
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.
|
||||
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.
|
||||
|
||||
## Phase 4 — Deliver
|
||||
## Step 4 — Deliver
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Models
|
||||
|
||||
| Who | Model | Why |
|
||||
|---|---|---|
|
||||
| The lead (phase 0 decisions, the context files, following and reporting) | larger | judgement |
|
||||
| The lead (step 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 (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 |
|
||||
| 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 |
|
||||
| Decision records | larger | judgement |
|
||||
|
||||
Every agent the mesh runs draws on one weekly allowance shared by every session, and the larger model
|
||||
@@ -182,58 +485,112 @@ 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 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` |
|
||||
| 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 |
|
||||
|
||||
**Every five minutes, every two minutes while a delivery plan runs, and at once when something lands, stops or
|
||||
**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
|
||||
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), the issues as a tree: grouped by repository, each issue under the one it
|
||||
waits for, marked ✓ landed, ▶ fixing, ⏸ waiting, ✗ stopped:
|
||||
**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:
|
||||
|
||||
```
|
||||
Feature <slug> · step <n> of <m> · phase <name>, <minutes used> of <bound> min
|
||||
Feature <slug> · milestone <n> of <m> · step <name>, <minutes used> of <bound> min
|
||||
Issues: <landed> of <total> landed · <fixing> fixing · <waiting> waiting
|
||||
|
||||
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)
|
||||
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>
|
||||
|
||||
Opened along the way: <each issue opened during the feature, its number and a few words, and where it goes> — or "none"
|
||||
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"
|
||||
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 action, one line>
|
||||
Next: <the next actions up to the delivery, in order>
|
||||
```
|
||||
|
||||
**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> step <n>/<m> · <phase> <used>/<bound> min · <landed>/<total> landed · past bound: <none | what> · needs you: <nothing | what> · week <n> %
|
||||
<slug> milestone <n>/<m> · <step> <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 step's issues included, also when only time passed: the operator reads the state from it without
|
||||
tree of the milestone'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 phase, and whenever the session's context grows large or spread over unrelated work, the
|
||||
At the end of each step, 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 phase, its branches and heads, the context files' paths, the agents' ids, the wait list and the
|
||||
feature's step, 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 step that stops, for a wait past its bound, a
|
||||
An issue stays in `fixing` only while an agent works on it. A milestone 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.
|
||||
@@ -242,17 +599,56 @@ 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
|
||||
phase 1, is the failure this playbook prevents.
|
||||
step 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.
|
||||
- More than one open pull request in a repository that share no feature name, or a feature step whose issues
|
||||
- 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
|
||||
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, or a fourth round, is the failure of the
|
||||
- A review report that asks a change outside the issues' done-when and acceptance criteria, 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 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 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 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.
|
||||
|
||||
|
||||
@@ -16,47 +16,88 @@ 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**. Everything before the push is local and fast.
|
||||
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.
|
||||
|
||||
## 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, or by the operator. A fix that turns out to
|
||||
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
|
||||
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.
|
||||
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).
|
||||
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`. 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
|
||||
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
|
||||
its id, so the fix keeps its context. A playbook or design edit of a few lines may skip the reviewer: the
|
||||
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
|
||||
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
|
||||
(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.
|
||||
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 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.
|
||||
|
||||
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
|
||||
@@ -77,10 +118,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 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` |
|
||||
| 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 |
|
||||
|
||||
A wait past its bound is said to the operator at once, with what is being done about it.
|
||||
|
||||
@@ -92,10 +133,22 @@ 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 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.
|
||||
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.
|
||||
- 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).
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,521 @@
|
||||
<!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,"&").replace(/</g,"<")}
|
||||
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>
|
||||
@@ -36,6 +36,7 @@ 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.
|
||||
|
||||
@@ -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. `index.py` checks that they are written.
|
||||
and refuses a record whose topic is not one of them. the index test (`00-META/checks/`) 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
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
---
|
||||
layer: as-is
|
||||
status: implemented
|
||||
code: [mesh-controller, mesh-catalog modules/mesh-delivery, mesh-catalog modules/gitea, mesh-catalog modules/build-agent]
|
||||
updated: 2026-10-17
|
||||
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
|
||||
decisions:
|
||||
- "ADR 0239"
|
||||
- "ADR 0267"
|
||||
- "ADR 0276"
|
||||
- "ADR 0282"
|
||||
- "ADR 0289"
|
||||
- "ADR 0304"
|
||||
@@ -17,74 +16,120 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Who does what
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## From a merge to a rollout
|
||||
## From a merge to the nodes
|
||||
|
||||
- **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.
|
||||
- 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).
|
||||
|
||||
## Where the record of what runs lives
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## What is not covered
|
||||
|
||||
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).
|
||||
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`).
|
||||
|
||||
@@ -1,69 +1,153 @@
|
||||
---
|
||||
layer: as-is
|
||||
status: implemented
|
||||
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
|
||||
code: [mesh-tools, mesh-host, mesh-controller]
|
||||
updated: 2026-10-26
|
||||
decisions:
|
||||
- "ADR 0539"
|
||||
- "ADR 0360"
|
||||
- "ADR 0524"
|
||||
- "ADR 0525"
|
||||
- "ADR 0152"
|
||||
- "ADR 0095"
|
||||
- "ADR 0037"
|
||||
- "ADR 0175"
|
||||
- "ADR 0195"
|
||||
- "ADR 0197"
|
||||
- "ADR 0367"
|
||||
- "ADR 0154"
|
||||
---
|
||||
|
||||
# The mesh MCP server, as it runs
|
||||
|
||||
**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.
|
||||
**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`.
|
||||
|
||||
## What it answers
|
||||
|
||||
`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.
|
||||
`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`).
|
||||
|
||||
`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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
## The mesh's own verbs
|
||||
|
||||
*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.
|
||||
**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.
|
||||
|
||||
## Around it
|
||||
## Where it runs
|
||||
|
||||
- **`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.
|
||||
**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.
|
||||
|
||||
## What shipped bent
|
||||
## What this repository's prior text got wrong
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
layer: as-is
|
||||
status: implemented
|
||||
code: [mesh-controller, mesh-tools]
|
||||
updated: 2026-10-16
|
||||
updated: 2026-10-19
|
||||
decisions:
|
||||
- "ADR 0106"
|
||||
- "ADR 0436"
|
||||
- "ADR 0438"
|
||||
---
|
||||
|
||||
# Logins on the bus, as they run
|
||||
@@ -25,6 +27,16 @@ 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.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
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).
|
||||
@@ -25,6 +25,8 @@ 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
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: proposed
|
||||
status: in-progress
|
||||
code:
|
||||
- mesh-controller cmd/mesh-builder
|
||||
- mesh-controller internal/builder
|
||||
- mesh-catalog modules/build-agent
|
||||
updated: 2026-10-16
|
||||
- mesh-controller modules/build-agent
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0405"
|
||||
- "ADR 0262"
|
||||
@@ -31,6 +31,8 @@ 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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: proposed
|
||||
status: in-progress
|
||||
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-06
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0224"
|
||||
- "ADR 0230"
|
||||
@@ -24,6 +24,8 @@ 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: proposed
|
||||
status: in-progress
|
||||
code:
|
||||
- mesh-host internal/bootstrap
|
||||
- mesh-host cmd/mesh-bootstrap
|
||||
- mesh-lab test/integration/one-node-mesh.test.ts
|
||||
updated: 2026-10-06
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0226"
|
||||
- "ADR 0067"
|
||||
@@ -16,6 +16,8 @@ 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.
|
||||
|
||||
@@ -6,7 +6,7 @@ code:
|
||||
- mesh-controller
|
||||
- mesh-catalog
|
||||
- mesh-lab
|
||||
updated: 2026-09-15
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0067"
|
||||
- "ADR 0074"
|
||||
@@ -17,6 +17,8 @@ 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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-09-25
|
||||
status: in-progress
|
||||
code: [mesh-controller]
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0084"
|
||||
- "ADR 0027"
|
||||
@@ -11,6 +11,8 @@ 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.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: implemented
|
||||
code: [mesh-catalog, mesh-controller, mesh-host, mesh-media-catalog]
|
||||
updated: 2026-10-12
|
||||
status: in-progress
|
||||
code: [mesh-catalog, mesh-controller, mesh-host, mesh-media-catalog, mesh-vault]
|
||||
updated: 2026-10-26
|
||||
decisions:
|
||||
- "ADR 0550"
|
||||
- "ADR 0305"
|
||||
- "ADR 0158"
|
||||
- "ADR 0094"
|
||||
@@ -31,9 +32,10 @@ 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 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.
|
||||
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.
|
||||
- **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.**
|
||||
@@ -45,6 +47,56 @@ 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
|
||||
@@ -77,8 +129,10 @@ 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, 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.
|
||||
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).
|
||||
|
||||
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-16
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0181"
|
||||
- "ADR 0182"
|
||||
@@ -16,6 +16,11 @@ 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
|
||||
@@ -58,6 +63,15 @@ 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
|
||||
@@ -84,6 +98,9 @@ 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: proposed
|
||||
code: []
|
||||
updated: 2026-10-06
|
||||
status: in-progress
|
||||
code: [mesh-controller]
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0238"
|
||||
- "ADR 0221"
|
||||
@@ -15,6 +15,8 @@ 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
|
||||
@@ -166,7 +168,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 merge gate and a pull request's check ask them. Only a commit on the
|
||||
the merge handler, the what-if, the composition check 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)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: [mesh-catalog, mesh-tools, mesh-controller]
|
||||
updated: 2026-10-03
|
||||
status: implemented
|
||||
code: [mesh-tools, mesh-host, mesh-controller]
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0197"
|
||||
- "ADR 0195"
|
||||
- "ADR 0175"
|
||||
- "ADR 0367"
|
||||
- "ADR 0160"
|
||||
- "ADR 0159"
|
||||
- "ADR 0152"
|
||||
@@ -163,11 +165,16 @@ matched it by URL.
|
||||
|
||||
## What this does not settle
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-catalog modules/claude-code]
|
||||
updated: 2026-10-13
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0411"
|
||||
- "ADR 0412"
|
||||
- "ADR 0343"
|
||||
- "ADR 0274"
|
||||
- "ADR 0273"
|
||||
@@ -155,6 +157,16 @@ 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
|
||||
@@ -306,21 +318,27 @@ 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.*
|
||||
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.
|
||||
*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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**What the module declares.**
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- `~/.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.
|
||||
@@ -397,6 +415,7 @@ 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,9 +1,10 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: implemented
|
||||
code: [mesh-catalog modules/claude-licence-manager]
|
||||
updated: 2026-10-04
|
||||
status: in-progress
|
||||
code: [mesh-catalog modules/claude-licence-manager, mesh-vault]
|
||||
updated: 2026-10-26
|
||||
decisions:
|
||||
- "ADR 0550"
|
||||
- "ADR 0209"
|
||||
- "ADR 0206"
|
||||
- "ADR 0183"
|
||||
@@ -18,13 +19,35 @@ decisions:
|
||||
|
||||
# 39 — The Anthropic licence manager
|
||||
|
||||
**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.**
|
||||
**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).
|
||||
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
|
||||
@@ -43,7 +66,8 @@ 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.
|
||||
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).
|
||||
|
||||
## 2. The licences it manages today
|
||||
|
||||
@@ -77,7 +101,9 @@ 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.**
|
||||
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.
|
||||
|
||||
- **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.
|
||||
@@ -162,7 +188,8 @@ 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). The manager asks a node for a candidate grant by the agent module's own tool.
|
||||
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.
|
||||
|
||||
## 8. Settings
|
||||
|
||||
@@ -179,6 +206,8 @@ 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-14
|
||||
updated: 2026-10-22
|
||||
decisions:
|
||||
- "ADR 0369"
|
||||
- "ADR 0303"
|
||||
@@ -26,6 +26,9 @@ decisions:
|
||||
- "ADR 0164"
|
||||
- "ADR 0165"
|
||||
- "ADR 0166"
|
||||
- "ADR 0507"
|
||||
- "ADR 0508"
|
||||
- "ADR 0510"
|
||||
---
|
||||
|
||||
# 42. The machines' modules, in order
|
||||
@@ -69,14 +72,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 | mirrors generated once and never again; caches never cleaned |
|
||||
| 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 |
|
||||
| 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, 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) |
|
||||
| 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 |
|
||||
|
||||
`systemd`, `pacman` and `docker` hold the three seats that apply resources
|
||||
(ADR 0207):
|
||||
@@ -101,6 +104,33 @@ 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
|
||||
merge gate), **mesh-sdk** (the TypeScript providers' loop and its retirement), **mesh-lab** (the
|
||||
composition check), **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 merge gate 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 composition check 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 merge gate.** In mesh-controller, `mesh-host` and mesh-catalog, a check composes every machine of
|
||||
**The composition check.** 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 merge gate; versions tested as run; the resolver's tests under both C libraries |
|
||||
| mesh-controller, `mesh-host`, mesh-catalog | the compose-and-validate check; 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 merge gate's first runs against the live mesh's snapshot;
|
||||
rest of the window's incidents as replays; the composition check'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 merge gate refuses a declaration, which
|
||||
catalogue declares only once that controller runs: until then the composition check 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
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [hq 00-META/checks/words.py, mesh-catalog merge-check.sh]
|
||||
updated: 2026-10-11
|
||||
code: [hq 00-META/checks/words_test.go, mesh-catalog merge-check.sh]
|
||||
updated: 2026-10-20
|
||||
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 eleven domains, which owns its one word. The glossary is
|
||||
**Every concept of the mesh belongs to one of thirteen 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.)
|
||||
(ADR 0244; the eleventh, Media, ADR 0302; the twelfth, Config, ADR 0354; the thirteenth, Mail, ADR 0458.)
|
||||
|
||||
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
|
||||
@@ -31,12 +34,14 @@ 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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **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.
|
||||
@@ -61,7 +66,7 @@ a change of meaning upstream ripples down, never up. Arrows point downstream.
|
||||
+-------------> Health and repair <-------------------+
|
||||
| |
|
||||
v v
|
||||
Change and delivery Operator and conversation
|
||||
Delivery Operator and conversation
|
||||
| ^
|
||||
+-------------------+
|
||||
(a held delivery waits for a person's word)
|
||||
@@ -70,7 +75,12 @@ 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.
|
||||
|
||||
Change and delivery sits low because it reads health and data; in time it runs first, and asks Placement
|
||||
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
|
||||
to send. An authorising answer acts in whichever domain asked, through that domain's own verb: the
|
||||
conversation owns the asking, never the act.
|
||||
|
||||
@@ -84,7 +94,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 | Change and delivery — owned by the `mesh-delivery` module, not by the controller (ADR 0239) |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
@@ -117,7 +127,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 `words.py` runs on every pull request as part of the repository
|
||||
- **In this repository**, the hq check (the words test, `go test ./...`) 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
|
||||
|
||||
@@ -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-11
|
||||
updated: 2026-10-26
|
||||
decisions:
|
||||
- "ADR 0275"
|
||||
- "ADR 0278"
|
||||
@@ -13,6 +13,7 @@ decisions:
|
||||
- "ADR 0291"
|
||||
- "ADR 0294"
|
||||
- "ADR 0300"
|
||||
- "ADR 0493"
|
||||
---
|
||||
|
||||
# 53 — The issue tracker
|
||||
@@ -81,7 +82,10 @@ 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 | 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 |
|
||||
| `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) |
|
||||
|
||||
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.
|
||||
@@ -100,8 +104,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 decision record, a design, a
|
||||
condition key (the issue **tracks** that condition, § 12) or a delivery;
|
||||
- `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);
|
||||
- `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
|
||||
@@ -203,6 +207,7 @@ 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
|
||||
@@ -312,6 +317,32 @@ 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: designed
|
||||
code: []
|
||||
updated: 2026-10-10
|
||||
status: in-progress
|
||||
code: [mesh-controller]
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0289"
|
||||
- "ADR 0201"
|
||||
@@ -12,6 +12,8 @@ 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,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-10-12
|
||||
status: in-progress
|
||||
code: [mesh-catalog, hq]
|
||||
updated: 2026-10-18
|
||||
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
|
||||
@@ -26,7 +30,8 @@ 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).
|
||||
[§ 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).
|
||||
|
||||
## 1. The parts
|
||||
|
||||
@@ -39,9 +44,12 @@ This document designs the first slice in detail, tracked by issue 425. The slice
|
||||
├ 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 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)
|
||||
|
||||
mesh-issues (control-node) the counters for decision and research numbers (§ 6)
|
||||
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)
|
||||
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
|
||||
@@ -55,9 +63,11 @@ the records module, as the issue tracker's import does.
|
||||
|
||||
## 2. What one record holds
|
||||
|
||||
- `number` and `kind` (decision or research), its key together; `title`, `topic`, `status`, `date`, `deciders`;
|
||||
- `number` and `kind` (decision or research; with the third slice also design and glossary entry, § 9), 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; markdown, as written;
|
||||
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;
|
||||
- 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
|
||||
@@ -190,11 +200,89 @@ 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, playbooks and glossary entries as kinds | `holds` answers for a design's decisions and a glossary word | issue 428 |
|
||||
| 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 |
|
||||
| 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).
|
||||
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.
|
||||
|
||||
## 10. How it is checked
|
||||
|
||||
@@ -212,6 +300,26 @@ repository keeps no copy of the records, generated or otherwise (issue 435, answ
|
||||
| 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: designed
|
||||
code: []
|
||||
updated: 2026-10-13
|
||||
status: in-progress
|
||||
code: [mesh-catalog, mesh-controller]
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0316"
|
||||
- "ADR 0317"
|
||||
@@ -12,6 +12,8 @@ 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: designed
|
||||
status: in-progress
|
||||
code: [mesh-controller, mesh-host, mesh-sdk, mesh-catalog]
|
||||
updated: 2026-10-14
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0348"
|
||||
- "ADR 0349"
|
||||
@@ -61,6 +61,10 @@ 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
|
||||
@@ -147,15 +151,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 merge gate read templates from the repository tree beside the
|
||||
are unchanged; `module check` and the composition check 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 merge gate
|
||||
with its text in `files/` composes byte-identically to the same module with it inline; the composition check
|
||||
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 merge gate's plan says no file changes).
|
||||
nodes is unchanged (the composition check'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.
|
||||
@@ -333,6 +337,17 @@ 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-16
|
||||
updated: 2026-10-17
|
||||
decisions:
|
||||
- "ADR 0383"
|
||||
- "ADR 0384"
|
||||
@@ -12,6 +12,10 @@ decisions:
|
||||
- "ADR 0388"
|
||||
- "ADR 0389"
|
||||
- "ADR 0390"
|
||||
- "ADR 0411"
|
||||
- "ADR 0412"
|
||||
- "ADR 0413"
|
||||
- "ADR 0414"
|
||||
---
|
||||
|
||||
# 58 — Agent work as the mesh's own
|
||||
@@ -21,8 +25,10 @@ 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.**
|
||||
(ADR 0383 to ADR 0390, from research 053.)
|
||||
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.)
|
||||
|
||||
**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
|
||||
@@ -106,6 +112,95 @@ 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:
|
||||
@@ -156,12 +251,36 @@ 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). The issues are opened once the operator confirms
|
||||
[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
|
||||
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
|
||||
@@ -186,3 +305,18 @@ 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.
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
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
@@ -0,0 +1,213 @@
|
||||
---
|
||||
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.
|
||||
@@ -58,6 +58,11 @@ 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
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
[`cycle.py`](../../00-META/checks/cycle.py) checks it: an accepted record dated from 2026-10-09, or an
|
||||
the cycle test (`cycle.py` then) 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
|
||||
|
||||
@@ -22,8 +22,9 @@ 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 [`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.
|
||||
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.
|
||||
|
||||
## Where to look (before assuming anything)
|
||||
|
||||
@@ -49,13 +50,19 @@ 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 `00-META/checks/words.py` fails on it
|
||||
words. A retired word is named on its replacement's *Not:* line, and the words test in `00-META/checks/` fails on it
|
||||
in running prose; a quotation keeps its words.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **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).
|
||||
- **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.
|
||||
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
|
||||
@@ -64,7 +71,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)); `records.py` refuses a
|
||||
[01](00-META/process/01-research.md) and [02](00-META/process/02-graduation.md)); the records test 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
|
||||
|
||||
@@ -40,7 +40,7 @@ Implementation lives in the code repositories — see
|
||||
|
||||
## Rules
|
||||
|
||||
- Markdown only.
|
||||
- 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`).
|
||||
- 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.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user