ADR 0080: the development cycle is checked, not trusted

The flow the process overview draws — idea/symptom -> decision -> to-be design -> code ->
as-is — was enforced by nothing. cycle.py now refuses a to-be design naming no decision, an
in-progress/implemented design naming no owning code, a located/fixed issue with no owner,
a fixed/resolved issue with no fix, and a graduated research overview that does not say what
it became. AGENTS.md carries the cycle and a where-to-look table so a fresh session (or a
cleared context) finds the chain in frontmatter instead of assuming it. Grounding the check
surfaced two real gaps, fixed here: the work-ahead design named no owning code, and research
003 listed one became target twice.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-17 22:28:03 +02:00
parent 94fee5d849
commit becae7ba51
8 changed files with 258 additions and 1 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())
+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 -->
+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
+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