Merge pull request 'ADR 0080: the development cycle is checked, not trusted — plus review fixes' (#48) from process/the-development-cycle into main

This commit was merged in pull request #48.
This commit is contained in:
2026-09-17 22:28:22 +02:00
14 changed files with 273 additions and 11 deletions
+7
View File
@@ -48,3 +48,10 @@ what to do.
State what incident it would have caught, and make it fail before you make it pass. A check 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. whose failure has never been observed is a guess about its own correctness.
## cycle.py
The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
a to-be design names a decision, an in-progress/implemented design names its owning code, a
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
research overview says what it became. `python3 00-META/checks/cycle.py`
+152
View File
@@ -0,0 +1,152 @@
#!/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 status 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 a known `status:`; once `located` or `fixed`, `located-in:` names the owner;
once `fixed` or `resolved`, `fixed-by:` says what fixed it (prose counts --
"nothing, the capability existed" is an answer).
research a known `status:`; a `graduated` overview says what it `became:`, and every
target it names exists.
Deliberately NOT enforced: `resolved` issues may leave `located-in` empty (a symptom that
turned out not to be a defect has no owner), and 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"}
ISSUE_STATUSES = {"open", "diagnosing", "located", "fixed", "resolved"}
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
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 ------------------------------------------------------------------------
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
status = front.get("status")
if status not in ISSUE_STATUSES:
bad(path, "status %r is not one of %s" % (status, sorted(ISSUE_STATUSES)))
if status in ("located", "fixed") and not listy(front, "located-in"):
bad(path, "status %s but located-in is empty" % status)
if status in ("fixed", "resolved") and not listy(front, "fixed-by"):
bad(path, "status %s but fixed-by says nothing" % status)
# ---- research ----------------------------------------------------------------------
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
front = frontmatter(path)
if front is None:
bad(path, "no frontmatter")
continue
status = front.get("status")
if status not in RESEARCH_STATUSES:
bad(path, "status %r is not one of %s" % (status, sorted(RESEARCH_STATUSES)))
if status == "graduated":
became = listy(front, "became")
if not became:
bad(path, "graduated but became: names nothing")
for target in became:
if not os.path.exists(os.path.join(ROOT, target)):
bad(path, "became names %s, which does not exist" % target)
checked = (
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
+ len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.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())
+2 -2
View File
@@ -9,7 +9,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is - **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
just a machine that has joined; being one implies nothing about what it runs. just a machine that has joined; being one implies nothing about what it runs.
- **control-node** — the one node that also holds the `the-controller` seat. There is exactly one - **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
the controller (and, today, the foundation). Lose it and the other nodes keep running what they 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. were last told; they simply cannot be told anything new.
@@ -21,7 +21,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
- **controller** — the component that decides what each node should be, holds the mesh's records, - **controller** — the component that decides what each node should be, holds the mesh's records,
and tells nodes over the broker. Replaces **"control plane"** (borrowed from networking's and tells nodes over the broker. Replaces **"control plane"** (borrowed from networking's
control-plane/data-plane, and opaque here). control-plane/data-plane, and opaque here).
- **mesh-controller** — the module that runs the controller. It **claims** the `the-controller` - **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**. seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module, (The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
container and image it produces are `mesh-controller`.) container and image it produces are `mesh-controller`.)
+11
View File
@@ -52,6 +52,17 @@ the expensive half.
| [06](06-writing-a-module.md) | Writing a module | Something that runs today must run on the mesh | | [06](06-writing-a-module.md) | Writing a module | Something that runs today must run on the mesh |
| [07](07-feature-branches.md) | Feature branches across repos | Work that changes code, in one repo or several at once | | [07](07-feature-branches.md) | Feature branches across repos | Work that changes code, in one repo or several at once |
## 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
decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
`located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
research overview that does not say what it became. Run it with `records.py` and `index.py`
before any HQ merge. What the checks cannot see — that code work actually started from a
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
a feature branch exists because a design or an issue sent it.
## Status lives in frontmatter ## Status lives in frontmatter
Research overviews, design docs, issue reports and decision records each carry their status as Research overviews, design docs, issue reports and decision records each carry their status as
@@ -3,7 +3,6 @@ status: graduated
initiated: 2026-08-22 initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/05-runtime-and-installation.md] touches: [03-DESIGN/00-as-is/05-runtime-and-installation.md]
became: became:
- 02-DECISIONS/0005-the-node-host.md
- 02-DECISIONS/0005-the-node-host.md - 02-DECISIONS/0005-the-node-host.md
- 03-DESIGN/01-to-be/05-the-node-host.md - 03-DESIGN/01-to-be/05-the-node-host.md
--- ---
@@ -0,0 +1,55 @@
---
topic: how we work
status: accepted
date: 2026-09-17
deciders: jochen
reconstructed: false
extends: 0019-how-this-repository-works.md
---
# 80. The development cycle is checked, not trusted
## Context
[ADR 0019](0019-how-this-repository-works.md) made this repository the source of truth, and the
process overview drew the flow work must follow: an idea or a symptom, a decision, a to-be design,
a build in a code repository, an as-is update on shipping. The playbooks describe every step, and
frontmatter carries every status.
But the flow itself was enforced by nothing. A design could appear citing no decision; a design
could sit `in-progress` naming no code; an issue could be `fixed` by nobody knows what. Each is
indistinguishable from correct work until somebody reads carefully — and the whole point of the
playbooks is that nobody should have to hold this repository in their head. A session that starts
cold (or an agent after a context clear) must be able to *find* the chain by following frontmatter
pointers, which only works if the pointers are reliably there.
## Decision
The development cycle is enforced mechanically, to the extent frontmatter can carry it:
- **No design without a decision** — every to-be design names at least one record in `decisions:`.
- **No development without a design that says where** — an `in-progress` or `implemented` design
names its owning code in `code:`.
- **No owner-less diagnosis, no fix-less fix** — an issue marked `located` or `fixed` names
`located-in:`; one marked `fixed` or `resolved` says `fixed-by:` (prose counts — "nothing, the
capability existed" is an answer).
- **No silent graduation** — a `graduated` research overview says what it `became:`, and the
targets exist.
[`00-META/checks/cycle.py`](../00-META/checks/cycle.py) refuses violations, beside `records.py`
and `index.py`; all three run before any merge here. What frontmatter cannot see — that code work
actually started from a handoff — remains held by playbooks 04 and 07: a feature branch exists
because a design or an issue sent it, and a merge is a human checkpoint.
## Consequences
A `/clear` costs little: [`AGENTS.md`](../AGENTS.md) now carries the cycle and a where-to-look
table, and the chain a fresh session needs is guaranteed present in frontmatter rather than
reconstructed from memory. The checks are the floor, not the ceiling — they verify pointers exist,
not that their content is true; reading remains the job.
## References
- [`00-META/process/00-overview.md`](../00-META/process/00-overview.md) — the flow, and its new
"The cycle is checked" section.
- [ADR 0019](0019-how-this-repository-works.md) — the repository this disciplines.
+1
View File
@@ -165,5 +165,6 @@ python3 00-META/checks/index.py fail if stale
- **0025** — [The design record is read where it is written, never copied to be found](0025-the-design-record-is-read-not-copied.md) - **0025** — [The design record is read where it is written, never copied to be found](0025-the-design-record-is-read-not-copied.md)
- **0032** — [The local account owns the mesh; a surface delegates to a module](0032-the-local-account-owns-the-mesh.md) *(superseded)* - **0032** — [The local account owns the mesh; a surface delegates to a module](0032-the-local-account-owns-the-mesh.md) *(superseded)*
- **0034** — [The local account owns the mesh, and a web application's login is not that](0034-the-local-account-owns-the-mesh.md) - **0034** — [The local account owns the mesh, and a web application's login is not that](0034-the-local-account-owns-the-mesh.md)
- **0080** — [The development cycle is checked, not trusted](0080-the-development-cycle-is-checked.md)
<!-- index:end --> <!-- index:end -->
+6
View File
@@ -23,6 +23,12 @@ decisions:
# The foundation # The foundation
**One store, one broker — enforced, not conventional.** Each foundation module claims a
mesh-scoped seat named after its server (`mesh-store`, `mesh-broker`, `mesh-controller` —
[ADR 0079](../../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)), so
assigning a second holder anywhere in the mesh is refused at resolution rather than silently
raising a second server.
Tier 1. Defined the same way [the controller](06-the-controller.md) is, because the same Tier 1. Defined the same way [the controller](06-the-controller.md) is, because the same
gap applied: the word was load-bearing and unpinned. gap applied: the word was load-bearing and unpinned.
@@ -129,8 +129,8 @@ two.
| # | module | provides | note | | # | module | provides | note |
|---|---|---|---| |---|---|---|---|
| 1 | `postgres` | `postgres-database` | **the controller's own records and every module's.** One server, not two | | 1 | `postgres` | `postgres-database` | **the controller's own records and every module's.** One server, not two — it *claims* the mesh-scoped `mesh-store` seat, so a second is refused |
| 2 | `lavinmq` | `amqp` | the broker every node dials, and what modules are granted vhosts on | | 2 | `lavinmq` | `amqp` | the broker every node dials, and what modules are granted vhosts on — *claims* `mesh-broker`, one per mesh |
| 3 | `mesh-controller` | *claims* `mesh-controller` | decides what runs where | | 3 | `mesh-controller` | *claims* `mesh-controller` | decides what runs where |
| 4 | `distribution` | `artifact-store` | what the mesh built, pinned by digest — the module is the software (Distribution), the provision is the job | | 4 | `distribution` | `artifact-store` | what the mesh built, pinned by digest — the module is the software (Distribution), the provision is the job |
| 5 | `builder` | — | turns source into artifacts | | 5 | `builder` | — | turns source into artifacts |
+5
View File
@@ -1,6 +1,11 @@
--- ---
layer: to-be layer: to-be
status: in-progress status: in-progress
code:
- mesh-host
- mesh-controller
- mesh-catalog
- mesh-lab
updated: 2026-09-15 updated: 2026-09-15
decisions: decisions:
- 02-DECISIONS/0067-genesis-is-a-pivot.md - 02-DECISIONS/0067-genesis-is-a-pivot.md
@@ -2,10 +2,9 @@
status: resolved status: resolved
opened: 2026-09-16 opened: 2026-09-16
located-in: located-in:
- mesh-controller/cmd/mesh-controller/modules.go - mesh-controller
- mesh-controller/cmd/mesh-controller/build.go - mesh-catalog
- mesh-catalog/modules/lavinmq/module.json fixed-by: mesh-controller PR 27 (5718add); mesh-catalog PR 24 (a24362b)
fixed-by: mesh-controller multi-node/broker-reaches-over-overlay; mesh-catalog fix/broker-declares-amqps-port
amended-design: amended-design:
--- ---
@@ -1,7 +1,7 @@
--- ---
status: resolved status: resolved
opened: 2026-09-17 opened: 2026-09-17
located-in: [mesh-controller, mesh-host] located-in: [mesh-controller, mesh-catalog]
fixed-by: mesh-catalog + mesh-controller (the foundation modules claim mesh-scoped seats) fixed-by: mesh-catalog + mesh-controller (the foundation modules claim mesh-scoped seats)
amended-design: 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md amended-design: 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
--- ---
@@ -6,7 +6,7 @@ fixed-by:
amended-design: amended-design:
--- ---
# 059 — A provisioner runtime crash-loops until the overlay is up # 058 — A provisioner runtime crash-loops until the overlay is up
## Symptom ## Symptom
+27
View File
@@ -12,6 +12,33 @@ through them. Thin skills in `.claude/skills/` wrap these playbooks for invocati
`hq-handoff`, `hq-sync-constitution`, `hq-status`); each defers to its playbook as `hq-handoff`, `hq-sync-constitution`, `hq-status`); each defers to its playbook as
authoritative and adds only the mechanical scaffolding. authoritative and adds only the mechanical scaffolding.
## The development cycle
Work enters as an **idea** (playbook [01 — research](00-META/process/01-research.md)) or a
**symptom** (playbook [03 — issues](00-META/process/03-issues.md)), becomes a **decision**
([02-DECISIONS](02-DECISIONS/), via playbook [02](00-META/process/02-graduation.md)), lands in a
**to-be design** naming that decision, is handed to a code repository (playbook
[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` and `index.py` — run all three before any merge here.
## Where to look (before assuming anything)
| Question | Read |
|---|---|
| What does this word mean? | [`00-META/glossary.md`](00-META/glossary.md) |
| How do I do X in this repo? | [`00-META/process/`](00-META/process/) — the playbook index is in `00-overview.md` |
| What was decided, and why? | [`02-DECISIONS/README.md`](02-DECISIONS/README.md) (reading order), then the record |
| What is being built / already runs? | [`03-DESIGN/01-to-be/`](03-DESIGN/01-to-be/) / [`03-DESIGN/00-as-is/`](03-DESIGN/00-as-is/) — each doc's frontmatter says its status, decisions and owning code |
| What is broken or was? | [`04-ISSUES/`](04-ISSUES/) — frontmatter carries status/owner/fix |
| Which repo owns what code? | [`00-META/repos.md`](00-META/repos.md) |
| Cross-cutting status view? | the `hq-status` skill (generated, never stored) |
Statuses live **only** in frontmatter; follow the pointers there (`decisions:`, `code:`,
`became:`, `fixed-by:`) instead of reconstructing history from memory.
## Words ## Words
One name per thing. [`00-META/glossary.md`](00-META/glossary.md) is the authority on One name per thing. [`00-META/glossary.md`](00-META/glossary.md) is the authority on