state invariant 7 as a rule the gate can hold

"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.
This commit is contained in:
Claude Opus 5
2026-08-01 23:21:46 +06:00
committed by bdeshi
parent ccce537ae4
commit 8a4718d775
4 changed files with 35 additions and 7 deletions
+4
View File
@@ -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 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/` 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. 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 - `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 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 commit trailing it (ADR-0057) — and fails when `cmd/` or
+2 -1
View File
@@ -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. 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. 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 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`). 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 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 — each is a shortcut around work the engine can still do itself, slowly. Anything that cannot
+19
View File
@@ -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 — 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. nothing can check that, since only the theme knows its own measure.
Revisit if: the engine ever learns a layout, which it should not. 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.
+10 -6
View File
@@ -333,14 +333,18 @@ layer() { # $1 = importing layer, $2..$n = layers it may not import
done done
} }
violations=$( violations=$(
layer content render web ext layer content render web
layer render web ext layer render web
layer web ext
echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}' 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). # Invariant 7, as a rule rather than a list: a feature is a leaf, so `cmd/` is the *only* thing that may
siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" ' # import one. Stated positively, this also covers a core package that does not exist yet — the enumeration
index($1,e)==1 && index($2,e)==1 && $1!=$2 {print $1" → "$2}') # 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 if [ -n "$violations$siblings" ]; then
bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')" bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')"
else else