From 8a4718d7750ddea151904a30749418ee5b5732ba Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 23:21:46 +0600 Subject: [PATCH] state invariant 7 as a rule the gate can hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Every feature is a leaf" was enforced by listing the pairs that happen to exist today: content, render and web may not import ext, plus a separate check for siblings. A list only forbids what is already there — a core package added next month could import a feature and pass, which is how an invariant rots while staying technically true. One positive rule now: only cmd/ may import internal/ext/…. It covers packages that do not exist yet and catches a sibling import in the same breath, since a feature importing another feature is the other way one stops being deletable. Four rules become one. Watched rejecting both kinds before keeping it — a core package importing a feature, and a feature importing its sibling — and watched passing the tree as it stands. Test files are excluded, which is right: the demo's own test wires features on purpose. Queue entry G5. It was the last architecture invariant held only by hand. --- HARNESS.md | 4 ++++ docs/architecture.md | 3 ++- docs/decisions.md | 19 +++++++++++++++++++ scripts/verify.sh | 16 ++++++++++------ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/HARNESS.md b/HARNESS.md index 8bb2b59..8fb52c9 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -216,6 +216,10 @@ that describe it, in the same change.** changing — except `.claude/settings.json`, which is Claude Code's permission list rather than anything about khosra. That exemption is one path: `scripts/budgets.env` and every other file under `scripts/` and `.claude/` stays gated, and khosra's own settings are not exempt from anything. This file is the current description of the machine, not a snapshot of its design. +- **Invariant 7 is mechanical now.** "Every feature is a leaf" means only `cmd/` may import + `internal/ext/…`, and `verify.sh` states that as one rule rather than a list of the pairs that happen to + exist today (ADR-0069) — so a core package added next month cannot quietly import a feature. A feature + importing its sibling fails the same rule. - `verify.sh` fails when a `.go` file changes without `docs/state.md` changing, warns when `state.md`'s last commit is older than the last `.go` one — the two together mean the doc ships inside the change, never in a commit trailing it (ADR-0057) — and fails when `cmd/` or diff --git a/docs/architecture.md b/docs/architecture.md index 179cd12..944d546 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,8 @@ is normal**: search will be an engine-built index and a browser-side query, not 5. **Permalinks are permanent.** Renames add aliases and redirects. URLs are not reused. 6. **Interactions are off the content graph.** A comment invalidates one fragment, never a build. 7. **Every feature is a leaf** — a View, Stage, Query, Interaction adapter, Effect, or - bundle-as-program, deletable without trauma. Anything wanting a permanent service or a core-model + bundle-as-program, deletable without trauma. Mechanical since ADR-0069: `verify.sh` allows `internal/ext/…` + to be imported from `cmd/` and nowhere else, siblings included. Anything wanting a permanent service or a core-model change is a **trunk** and waits for a human decision (`exploration.md`). 8. **The engine serves correctly with every external service off.** Redis, an index, object storage — each is a shortcut around work the engine can still do itself, slowly. Anything that cannot diff --git a/docs/decisions.md b/docs/decisions.md index eee87c9..40cab5c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1129,3 +1129,22 @@ Consequence: cheap — five attributes, no engine change, and the derivative pas — a theme that redefines these fragments and forgets `sizes` silently returns to the old behaviour, and nothing can check that, since only the theme knows its own measure. Revisit if: the engine ever learns a layout, which it should not. + +## ADR-0069 — Invariant 7 is a rule in the gate, not a list +Date: 2026-08-01 · Status: accepted (queue entry G5) +Decision: `verify.sh` enforces "only `cmd/` may import `internal/ext/…`" as one positive rule, replacing the +three enumerated pairs — `content → ext`, `render → ext`, `web → ext` — and the separate sibling check. Test +files are excluded, because `go list .Imports` omits them and the demo's own test exercises features on +purpose. +Why: the enumeration only forbade what already existed. A core package added tomorrow could import a feature +and pass, which is exactly how an invariant rots — the list stays true and stops being the rule. Stated +positively it also catches a sibling import in the same breath, since a feature importing another feature is +the other way one stops being deletable (ADR-0027). "Every feature is a leaf" is the property the whole +`internal/ext` split exists to protect, and it was the last architecture invariant held only by hand. +Consequence: cheap — one rule where there were four, and it covers packages that do not exist yet. Watched +rejecting both kinds: a core package importing a feature, and a feature importing its sibling. Expensive — +`cmd/` is now the only place a feature may be named, so a future test that wants to wire features must +either live in `cmd/` or duplicate the list, which is the latent item already recorded against +`example_test.go`. +Revisit if: features need to be composed somewhere a test can import, which is that latent item and not this +gate. diff --git a/scripts/verify.sh b/scripts/verify.sh index be1c9e9..a211817 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -333,14 +333,18 @@ layer() { # $1 = importing layer, $2..$n = layers it may not import done } violations=$( - layer content render web ext - layer render web ext - layer web ext + layer content render web + layer render web echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}' ) -# Sibling imports between features are what make an agent's read set compound (ADR-0027). -siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" ' - index($1,e)==1 && index($2,e)==1 && $1!=$2 {print $1" → "$2}') +# Invariant 7, as a rule rather than a list: a feature is a leaf, so `cmd/` is the *only* thing that may +# import one. Stated positively, this also covers a core package that does not exist yet — the enumeration +# above cannot, and the day someone adds `internal/foo` it would import `internal/ext` unnoticed. Sibling +# imports between features fail here too: they are what make an agent's read set compound (ADR-0027), and +# they are the other way a feature stops being deletable. Test files are excluded by `go list .Imports`, +# which is right — the demo's own test exercises features on purpose (ADR-0069). +siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" -v c="$mod/cmd" ' + index($2,e)==1 && index($1,c)!=1 {print $1" → "$2}') if [ -n "$violations$siblings" ]; then bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')" else