From 0f71c6ec599fd213e12a22f003579670ceb92c4a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 18 Aug 2026 03:25:33 +0000 Subject: [PATCH 1/4] refactor(constitution)!: split resident law from retrieved articles CONSTITUTION.md stood at 376 lines / ~5.4k tokens resident in every agent's context, in every consumer repo, on every turn. Root AGENTS.md (130) plus the constitution (376) made a 506-line static surface before a consumer added its own instruction file. Split on which mechanism catches the violation, never on document order: - CONSTITUTION.md keeps the Preamble, the Application block and Article V: 13 rules, and all 13 are gate: review. Nothing announces them and no lint, type or mutation gate catches them, so prose is the only channel they have. Resident, ~2.5k tokens. - CONSTITUTION-ARTICLES.md takes Articles I-IV: 21 rules that fire on an artifact the work already put in front of the agent, 13 of them backed by a mechanical gate (10 lint, 2 type-checker, 1 mutation). Retrieved on write or edit of a source file. Rejected a headers-only resident index. A title is not compliable, only citable, which is the one thing CONST-G1 forbids; and a plausible stub suppresses the fetch that retrieval depends on. The resident half carries whole rules and a five-row table naming artifact triggers, not rule text. No shim, no alias, no forwarding pointer. Consumers add a second symlink and wire the trigger in their own AGENTS.md. The gate moved in this commit because it had to: PATH was hardcoded to one file, so the split alone would have left the coverage comparison green over 13 of 34 rules. PATHS is now the corpus - ids unique across it, citations resolving across it (CONST-S4 cites CONST-T5 over the new boundary), a missing file hard-failing instead of scoring, and --against tolerating a file that did not exist at the old revision. Verified against three known-bad fixtures: absent half, duplicated id, dropped rule still cited. CONST-B6 landed on main while this was in flight and was carried into the retrieved half between B3 and B4, upstream's own position. Its gate is type-checker, which is the partition working as stated. Net lines are positive, per CONST-S4: the routing table, the articles head, and the per-file plumbing in the validator are new. Deleted in exchange - AGENTS.md startup step 2 (an eager read of a document already resident), one of two @CONSTITUTION.md references, README's stale "thirty-one rules", and the documented `pnpm test -- --against `, which argparse read as positionals and which never worked. package.json declares the validator's pyyaml dependency and .husky/pre-commit routes through it; pnpm test could not run on a clean clone. --- .husky/pre-commit | 2 +- AGENTS.md | 25 +-- CONSTITUTION-ARTICLES.md | 254 ++++++++++++++++++++++++++++++ CONSTITUTION.md | 262 ++----------------------------- README.md | 37 +++-- package.json | 2 +- scripts/validate-constitution.py | 76 ++++++--- 7 files changed, 362 insertions(+), 296 deletions(-) create mode 100644 CONSTITUTION-ARTICLES.md diff --git a/.husky/pre-commit b/.husky/pre-commit index 3a448c3..98475b5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -python3 scripts/validate-constitution.py +pnpm test diff --git a/AGENTS.md b/AGENTS.md index 6af0941..8fb0d73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,17 @@ # AGENTS.md — Constitution Repository -Single source of truth for [`CONSTITUTION.md`](CONSTITUTION.md) — the supreme design law of [System F Software](https://systemfsoftware.com). Consumer repos vendor via `git subtree` + symlink. This repo has no production code, no test suite, and no build step — it is a markdown document plus its governance tooling (commit validation, agent harness). +Single source of truth for the supreme design law of [System F Software](https://systemfsoftware.com). Consumer repos vendor via `git subtree` + symlink. This repo has no production code, no test suite, and no build step — it is two markdown documents plus their governance tooling (commit validation, agent harness): `CONSTITUTION.md`, resident in every agent's context, and `CONSTITUTION-ARTICLES.md`, retrieved on write or edit of a source file. + +@CONSTITUTION.md ## Startup Workflow Before making changes: 1. **Read this file** completely. -2. **Read @CONSTITUTION.md** — every directive in there binds this repo too. -3. **Confirm the active task** with the user or the agent's task list. -4. **Review recent commits** with `git log --oneline -5`. -5. **Ensure current branch is not `main`** — feature branches only. If on main, create one. +2. **Confirm the active task** with the user or the agent's task list. +3. **Review recent commits** with `git log --oneline -5`. +4. **Ensure current branch is not `main`** — feature branches only. If on main, create one. ## Working Rules @@ -22,6 +23,12 @@ Before making changes: ## Amending the Constitution +### Choosing the file + +A rule goes in `CONSTITUTION.md` when its harm fires before you would know to look it up: nothing announces it, and no mechanism catches it after the fact. It goes in `CONSTITUTION-ARTICLES.md` when the work announces it — an artifact is in front of you (a domain type, a decision function, a boundary object, a test, a module's name) and a trigger can fire on writing it. + +The partition is not gated, deliberately. The obvious key — resident means `gate: review` — is a field the rule's own author writes, and it is false anyway: `CONST-B1`, `CONST-T2` and `CONST-N3` are review-gated and artifact-announced. Check: review — which of the two conditions above the rule meets. What *is* gated is that the union stays whole; `pnpm test` reads both files, so a rule dropped in a move fails coverage and a rule left in both fails on its id. + ### Writing a rule A rule is a fenced YAML block carrying `id`, `title`, `gate`, `do`, `dont`, `harm`, `check`, and — only where wrong and right look alike — `example`, `scope`, or `layers`. Do not restate that list anywhere: `pnpm test` owns it, and rejects a missing field, an unknown one, or an unregistered `gate` value. @@ -67,7 +74,7 @@ This is gated rather than requested because the remaining defect is the quiet on |---|---|---| | **Locked** | `AGENTS.md`, `.husky/_/`, verification scripts | Read and propose changes; do not edit to make verification pass. | | **Editable** | `package.json`, `pnpm-lock.yaml`, `commitlint.config.cjs`, `.gitignore`, `.husky/` (hooks only, not `_/`) | Edit freely within the active task. | -| **Human-controlled** | `CONSTITUTION.md`, `README.md`, merging to `main`, pushing, destructive ops | Propose changes; ask the user before acting. | +| **Human-controlled** | `CONSTITUTION.md`, `CONSTITUTION-ARTICLES.md`, `README.md`, merging to `main`, pushing, destructive ops | Propose changes; ask the user before acting. | ## Definition of Done @@ -82,14 +89,14 @@ A task is done only when ALL of the following are true: ## Verification Commands ```bash -pnpm test # schema, coverage, ids, families, dangling citations +pnpm test # both files: schema, coverage, ids, families, dangling citations pnpm exec commitlint --from HEAD~1 ``` After a commit that deletes, splits, merges, or re-scopes a rule — not after every edit — also run the reassignment check against the revision before it: ```bash -pnpm test -- --against +pnpm test --against ``` ### Anti-Bypass Rules @@ -124,7 +131,7 @@ Before ending a session: ## Escalation -- **Constitution conflict**: Consult @CONSTITUTION.md. If letter and purpose diverge, purpose governs. +- **Constitution conflict**: `CONSTITUTION.md` is already in context — reread it there, not from disk. If letter and purpose diverge, purpose governs (`CONST-G1`). - **Unclear requirements**: Ask the user. - **Verification failure**: Record via memory, flag for review, do not bypass. - **Scope ambiguity**: Re-read this file and the Definition of Done. diff --git a/CONSTITUTION-ARTICLES.md b/CONSTITUTION-ARTICLES.md new file mode 100644 index 0000000..83e295f --- /dev/null +++ b/CONSTITUTION-ARTICLES.md @@ -0,0 +1,254 @@ +# Constitution — Articles + +Retrieved, not resident. `CONSTITUTION.md` is supreme and always in context; these four articles are its craft law, delivered when the work reaches the artifact each one governs. Everything in `CONSTITUTION.md` binds here unchanged — the Preamble on how to read a rule, the Application on how a principle is invoked and enforced, and Article V on conduct. + +Deliver this file on **write or edit** of a source file, never on read: an agent that greps, or works from a plan, never fires a read trigger. The trigger condition is the law's; the mechanism that fires it — a path-scoped rule, a pre-tool gate — is the consuming harness's, and belongs in that repo's `AGENTS.md`, never here. + +--- + +## Article I — The Pure Core + +```yaml +rules: + - id: CONST-P1 + title: Purity + gate: lint + do: each domain decision is a pure function — data in, a value or typed error out + dont: + - in a decision, do I/O, throw, read a clock, or use randomness + - return an effect handle from a decision — if it needs the runtime, move the boundary, not the purity + harm: logic untestable over all inputs, untrustworthy + check: lint — decisions import no I/O or effect-runtime; mutation + - id: CONST-D1 + title: Types Before Logic + gate: type-checker + do: define types before behavior; make illegal states unrepresentable so bad data fails to compile + dont: start from functions and add types after + harm: invalid data reaches runtime; tests multiply to cover what a type could forbid + check: type-checker rejects the illegal state; review + - id: CONST-D2 + title: Each Error Its Own Variant + gate: lint + do: give every distinct failure its own tagged variant + dont: distinguish failures by a boolean or string field + harm: callers can't branch on the real failure; distinct errors collapse into one case + check: lint; review — callers branch on the variant tag, never on a field value + - id: CONST-D3 + title: No Primitive Obsession + gate: lint + do: brand every domain-meaningful value (ids, amounts, codes) as its own type + dont: pass bare text or number in a domain-significant position + harm: values transposed or misused; the type says nothing about what they are + check: lint — no bare primitives in domain signatures + - id: CONST-D4 + title: Null Is Not a State — but absence is fine for optional data + gate: lint + do: + - model mutually-exclusive states as a tagged union — one variant per state, each carrying only its valid fields + - use a plain nullable for a value absent identically in every state + dont: + - encode a state by which fields are present + - wrap such a field in Option/Maybe to "fix" it — the wrapper renames the hole, not closes it + harm: a state machine hidden in a record; the compiler can't reject invalid field combinations — the question is never "null or Option" but "a value that may not exist, or a state in disguise" + check: lint — flags an optional that correlates with the discriminant, not plain optionals; review + example: + wrong: Order { status, shippedAt?, trackingId? } — state by presence; an Option wrapper is the same defect + right: Order = Pending { placedAt } | Shipped { placedAt, shippedAt, trackingId } + fine: Customer { name, middleName? } — genuinely optional; plain nullable, no wrapper + - id: CONST-P2 + title: The Pure Core Has One Path (Cyclomatic Complexity 1) + gate: lint + do: write each core decision as a single path — choice as exhaustive dispatch over a closed type (match a tagged union), iteration as map/fold; the core is an expression, not a procedure + dont: + - in the core, use if/else, switch, ?:, or &&/|| for control + - in the core, use for/while — repetition moves into map/fold + scope: binds the pure core (decision and workflow files); the ban is on the control-flow form, not branching — a core function reads as one path yet still decides and iterates; the shell sequences steps and carries no decisions, its only structure is the sandwich (CONST-B3); the gate runs on core files, not the shell + harm: every branch is an untested path where state silently diverges — the mutator reaches it, the suite does not + check: lint — cyclomatic complexity = 1 on core files (match, map, fold are calls, not control flow, so they hold at 1; if/switch/loops raise it) + example: + wrong: if (o.kind === "Shipped") ship(o) else hold(o) — two paths in a decision + right: match(o) { Shipped -> ship, Pending -> hold } — one exhaustive dispatch over a closed type + wrong_iteration: fold over the data with a for-loop in the core — iterating the core as a procedure + right_iteration: fold(xs, 0, add) — iterate as one expression (a shell loop is fine — that's the shell) +``` + +--- + +## Article II — The Boundary + +```yaml +rules: + - id: CONST-B1 + title: Functional Core, Imperative Shell + gate: review + do: split every module into a pure core (decisions) and a thin shell (I/O); pass plain serializable data across the seam + dont: let a boundary object (handler, adapter, middleware) make a decision — it only translates external ↔ domain + harm: decisions tangled with I/O can't be tested without mocks; bugs hide in the boundary + check: review — a boundary object that needs its own test suite has logic in it; move it to the core + - id: CONST-B2 + title: Effects Are Values + gate: lint + do: return effects as lazy values (descriptions), interpreted once at the edge; attach logging, metrics, tracing as decorators on the value + dont: + - put an eager async result (promise, future, task) on the public surface + - embed a cross-cutting concern in a decision + harm: an already-started result can't be held, retried, or swapped; embedded concerns can't be turned off or composed + check: lint — no eager async result on the public surface + example: + wrong: "getUser : UserId -> " + right: "getUser : UserId -> Effect — a lazy value, interpreted once at the edge" + - id: CONST-B3 + title: The I/O Sandwich + gate: review + do: shape every outside interaction as read (impure) → transform (pure) → write (impure); the shell calls the core directly + dont: insert a layer that only passes work through without a read, transform, or write + harm: side effects leak into business logic; pass-through layers add coupling for nothing + check: review — pass-through delegation is the violation; the shell doing the read/transform/write, or sitting between transport and core, is not + example: + flow: | + read → decode → decide → shape → write + impure bread (read, write) around a thick pure filling (decode, decide, shape), no I/O between the pure steps. + read pull raw inputs — store, gateway, network, clock (impure) + decode validate raw → branded domain types (fail as data) (pure) + decide one decision over typed data → Decision | Error (pure) + shape build outputs and events from the Decision (pure) + write persist · emit · respond (impure) + wrong: read → decide → read → decide — I/O interleaved; the filling turns impure + edge: a later read that depends on an earlier decision — pre-fetch it, split into two sandwiches, or keep it openly in the shell; never fake a "pure core" around it + - id: CONST-B6 + title: The Sandwich Order Is Carried by Types + gate: type-checker + do: express an outside interaction as one phase chain — each phase's return type carries the required member the next phase's parameter demands — so the order is a consequence of the types and the compiler decides it + dont: + - hand-sequence the phases and state their order beside them; an order asserted in prose is decided by nothing + - give the phases a hierarchy — where a later phase's type is assignable to an earlier phase's parameter, an inversion still compiles + harm: an order nothing decides permits every permutation while reading as a guarantee, so the interleaved read that turns the filling impure — the defect CONST-B3 names — reaches production with the rule green + check: type-checker — composing the phases in the wrong order omits the required member, so the compiler names the phase that must come first; the sentence survives into the published declaration as that member's own name, which is what carries it into a consumer's compiler + example: + wrong: "write(decide(read(raw))) — hand-sequenced; every permutation type-checks, so the order is a comment" + right: "read : Raw -> ReadDone, decode : ReadDone -> DecodeDone, decide : DecodeDone -> DecideDone — decode cannot receive what read has not produced" + - id: CONST-B4 + title: Dependencies Point Inward + gate: lint + do: let the shell import the core; wire all implementations at one composition root + dont: let the core import the shell, the database, or the framework + harm: a decision layer chained to infrastructure can't be tested or replaced + check: import-graph lint + - id: CONST-B5 + title: Decode, Never Cast + gate: lint + do: turn outside data (bytes, serialized text, a foreign type) into a domain type via a decode returning a typed result + dont: + - assert type with an unchecked cast (`as`, `as unknown as`, `as any`) + - assert type with a suppression comment + harm: a shape nothing verified; everything downstream trusts a check that never ran + check: lint — no unchecked casts or suppression comments on outside data + example: + wrong: config := value as Config + right: "config := decode(value) : Result" + - id: CONST-P3 + title: Purity Is Per Function, Not Per Folder + gate: review + do: judge pure-versus-effectful by return type alone + dont: infer it from a folder, package, or "library versus application" + harm: a database-driver mislabeled "pure," a parser "impure," because of where it lives + check: review — return type decides; the lint behind CONST-P1 + example: + pure: "decide : Command -> Result" + effectful: "load : OrderId -> Effect — owns effects" +``` + +--- + +## Article III — Verification + +```yaml +rules: + - id: CONST-T1 + title: The Testing Trophy + gate: review + do: invest widest at Composition, anchored by Static Analysis, made honest by Properties + dont: build a Test Pyramid — it buries logic in I/O and leaves the untested middle + harm: logic buried in I/O; the untested middle + check: review — layer investment follows the trophy widths + layers: + - width: ████ + name: Static Analysis + scope: type checker, linter, dependency (DAG) checks + catches: Entire bug classes, zero maintenance + - width: ██████ + name: Property Tests + scope: Core invariants, ACL roundtrips, schema conformance + catches: What the system guarantees + - width: ██████████████ + name: Composition Tests + scope: Mocked I/O, real use cases, error paths + catches: Real workflows through the I/O sandwich + - width: ███ + name: Contract Tests + scope: transport schema, CLI output + catches: External interface conformance + - id: CONST-T2 + title: Properties Over Examples + gate: review + do: prove the pure core with property tests; the type is the generator + dont: cover the core with hand-picked example unit tests + harm: a green suite that tests only the cases you imagined, breaking on every refactor + check: review — property-test presence on the core + - id: CONST-T3 + title: Mutation Is the Measure + gate: mutation + do: gate the core at a perfect mutation score; kill a survivor with a sharper property or by deleting the dead branch it exploits + dont: + - reach the number by a suppression comment + - reach the number by narrowing the mutated set + - reach the number by lowering the gate + harm: a score certifying tests that notice nothing + check: mutation gate (break = 100); lint banning suppression and scope-narrowing + - id: CONST-T4 + title: Behavior Lives Where the Mutator Sees It + gate: lint + do: put any code that can be wrong (transform, check, branch) in a file the mutator covers + dont: place behavior in a declaration file (types, schemas, constant data), excluded from mutation + harm: a bug hidden behind a perfect score, in a file nothing mutates + check: lint — declaration files contain no behavior + - id: CONST-T5 + title: Pin Behavior Before You Rebuild + gate: review + do: before refactoring, cover the inputs the existing code accepts, with tests over real examples + dont: trust a mutation or property score to protect behavior you haven't pinned — they're blind to behavior you delete + harm: a rebuild silently drops a capability; the clean score certifies the regression + check: characterization tests over real fixtures; review +``` + +--- + +## Article IV — Organization + +```yaml +rules: + - id: CONST-N1 + title: Organized by What It Does + gate: review + do: organize by workflow and capability; keep code that changes together, together + dont: organize by what the system has (entities, technical layers) + harm: one change scattered across the tree + check: review — one change touches one capability subtree + - id: CONST-N2 + title: Names Scream the Domain + gate: lint + do: name files and folders for the job they do — a name must answer "of what?" + dont: + - use layer names (`core`, `shell`) + - use junk drawers (`util`, `service`, `manager`) + - use a suffix no rule keys on + harm: files no one can locate; meaningless buckets + check: filename lint — allowed suffixes; banned layer and junk-drawer names + - id: CONST-N3 + title: Fits in the Head + gate: review + do: give a module one responsibility; split it when a test needs elaborate setup (the signal it has several) + dont: accumulate unrelated concerns in one module + harm: modules no one can fully reason about; brittle, sprawling tests + check: review — fixture difficulty is the decomposition signal +``` diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 4528f63..5ef8264 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -6,6 +6,19 @@ This document states **principles, not tools or languages**. The language, frame Every `harm` is the stated rationale for its rule, not a measured finding — none is a claim that the consequence has been observed at a given rate. Comply with the directive and its harm, not the prose around them, and read a `gate` as the only thing that establishes a rule held. +## The Retrieved Articles + +Articles I–IV are **retrieved, not resident** — `CONSTITUTION-ARTICLES.md`. Each fires on the artifact it governs, on write or edit, never on read: + +| Article | Fires when you are authoring | +| --- | --- | +| I — The Pure Core | a domain type, or a decision function | +| II — The Boundary | a handler, adapter, port, or composition root | +| III — Verification | a test, or a judgement about one | +| IV — Organization | a module's name, or its place in the tree | + +This document carries what no artifact announces and no mechanism catches. A rule whose harm fires before you would know to look lives here; a rule the work itself announces lives there. That is the whole partition — not importance, and not article order. + ## Application ```yaml @@ -64,255 +77,6 @@ rules: --- -## Article I — The Pure Core - -```yaml -rules: - - id: CONST-P1 - title: Purity - gate: lint - do: each domain decision is a pure function — data in, a value or typed error out - dont: - - in a decision, do I/O, throw, read a clock, or use randomness - - return an effect handle from a decision — if it needs the runtime, move the boundary, not the purity - harm: logic untestable over all inputs, untrustworthy - check: lint — decisions import no I/O or effect-runtime; mutation - - id: CONST-D1 - title: Types Before Logic - gate: type-checker - do: define types before behavior; make illegal states unrepresentable so bad data fails to compile - dont: start from functions and add types after - harm: invalid data reaches runtime; tests multiply to cover what a type could forbid - check: type-checker rejects the illegal state; review - - id: CONST-D2 - title: Each Error Its Own Variant - gate: lint - do: give every distinct failure its own tagged variant - dont: distinguish failures by a boolean or string field - harm: callers can't branch on the real failure; distinct errors collapse into one case - check: lint; review — callers branch on the variant tag, never on a field value - - id: CONST-D3 - title: No Primitive Obsession - gate: lint - do: brand every domain-meaningful value (ids, amounts, codes) as its own type - dont: pass bare text or number in a domain-significant position - harm: values transposed or misused; the type says nothing about what they are - check: lint — no bare primitives in domain signatures - - id: CONST-D4 - title: Null Is Not a State — but absence is fine for optional data - gate: lint - do: - - model mutually-exclusive states as a tagged union — one variant per state, each carrying only its valid fields - - use a plain nullable for a value absent identically in every state - dont: - - encode a state by which fields are present - - wrap such a field in Option/Maybe to "fix" it — the wrapper renames the hole, not closes it - harm: a state machine hidden in a record; the compiler can't reject invalid field combinations — the question is never "null or Option" but "a value that may not exist, or a state in disguise" - check: lint — flags an optional that correlates with the discriminant, not plain optionals; review - example: - wrong: Order { status, shippedAt?, trackingId? } — state by presence; an Option wrapper is the same defect - right: Order = Pending { placedAt } | Shipped { placedAt, shippedAt, trackingId } - fine: Customer { name, middleName? } — genuinely optional; plain nullable, no wrapper - - id: CONST-P2 - title: The Pure Core Has One Path (Cyclomatic Complexity 1) - gate: lint - do: write each core decision as a single path — choice as exhaustive dispatch over a closed type (match a tagged union), iteration as map/fold; the core is an expression, not a procedure - dont: - - in the core, use if/else, switch, ?:, or &&/|| for control - - in the core, use for/while — repetition moves into map/fold - scope: binds the pure core (decision and workflow files); the ban is on the control-flow form, not branching — a core function reads as one path yet still decides and iterates; the shell sequences steps and carries no decisions, its only structure is the sandwich (CONST-B3); the gate runs on core files, not the shell - harm: every branch is an untested path where state silently diverges — the mutator reaches it, the suite does not - check: lint — cyclomatic complexity = 1 on core files (match, map, fold are calls, not control flow, so they hold at 1; if/switch/loops raise it) - example: - wrong: if (o.kind === "Shipped") ship(o) else hold(o) — two paths in a decision - right: match(o) { Shipped -> ship, Pending -> hold } — one exhaustive dispatch over a closed type - wrong_iteration: fold over the data with a for-loop in the core — iterating the core as a procedure - right_iteration: fold(xs, 0, add) — iterate as one expression (a shell loop is fine — that's the shell) -``` - ---- - -## Article II — The Boundary - -```yaml -rules: - - id: CONST-B1 - title: Functional Core, Imperative Shell - gate: review - do: split every module into a pure core (decisions) and a thin shell (I/O); pass plain serializable data across the seam - dont: let a boundary object (handler, adapter, middleware) make a decision — it only translates external ↔ domain - harm: decisions tangled with I/O can't be tested without mocks; bugs hide in the boundary - check: review — a boundary object that needs its own test suite has logic in it; move it to the core - - id: CONST-B2 - title: Effects Are Values - gate: lint - do: return effects as lazy values (descriptions), interpreted once at the edge; attach logging, metrics, tracing as decorators on the value - dont: - - put an eager async result (promise, future, task) on the public surface - - embed a cross-cutting concern in a decision - harm: an already-started result can't be held, retried, or swapped; embedded concerns can't be turned off or composed - check: lint — no eager async result on the public surface - example: - wrong: "getUser : UserId -> " - right: "getUser : UserId -> Effect — a lazy value, interpreted once at the edge" - - id: CONST-B3 - title: The I/O Sandwich - gate: review - do: shape every outside interaction as read (impure) → transform (pure) → write (impure); the shell calls the core directly - dont: insert a layer that only passes work through without a read, transform, or write - harm: side effects leak into business logic; pass-through layers add coupling for nothing - check: review — pass-through delegation is the violation; the shell doing the read/transform/write, or sitting between transport and core, is not - example: - flow: | - read → decode → decide → shape → write - impure bread (read, write) around a thick pure filling (decode, decide, shape), no I/O between the pure steps. - read pull raw inputs — store, gateway, network, clock (impure) - decode validate raw → branded domain types (fail as data) (pure) - decide one decision over typed data → Decision | Error (pure) - shape build outputs and events from the Decision (pure) - write persist · emit · respond (impure) - wrong: read → decide → read → decide — I/O interleaved; the filling turns impure - edge: a later read that depends on an earlier decision — pre-fetch it, split into two sandwiches, or keep it openly in the shell; never fake a "pure core" around it - - id: CONST-B6 - title: The Sandwich Order Is Carried by Types - gate: type-checker - do: express an outside interaction as one phase chain — each phase's return type carries the required member the next phase's parameter demands — so the order is a consequence of the types and the compiler decides it - dont: - - hand-sequence the phases and state their order beside them; an order asserted in prose is decided by nothing - - give the phases a hierarchy — where a later phase's type is assignable to an earlier phase's parameter, an inversion still compiles - harm: an order nothing decides permits every permutation while reading as a guarantee, so the interleaved read that turns the filling impure — the defect CONST-B3 names — reaches production with the rule green - check: type-checker — composing the phases in the wrong order omits the required member, so the compiler names the phase that must come first; the sentence survives into the published declaration as that member's own name, which is what carries it into a consumer's compiler - example: - wrong: "write(decide(read(raw))) — hand-sequenced; every permutation type-checks, so the order is a comment" - right: "read : Raw -> ReadDone, decode : ReadDone -> DecodeDone, decide : DecodeDone -> DecideDone — decode cannot receive what read has not produced" - - id: CONST-B4 - title: Dependencies Point Inward - gate: lint - do: let the shell import the core; wire all implementations at one composition root - dont: let the core import the shell, the database, or the framework - harm: a decision layer chained to infrastructure can't be tested or replaced - check: import-graph lint - - id: CONST-B5 - title: Decode, Never Cast - gate: lint - do: turn outside data (bytes, serialized text, a foreign type) into a domain type via a decode returning a typed result - dont: - - assert type with an unchecked cast (`as`, `as unknown as`, `as any`) - - assert type with a suppression comment - harm: a shape nothing verified; everything downstream trusts a check that never ran - check: lint — no unchecked casts or suppression comments on outside data - example: - wrong: config := value as Config - right: "config := decode(value) : Result" - - id: CONST-P3 - title: Purity Is Per Function, Not Per Folder - gate: review - do: judge pure-versus-effectful by return type alone - dont: infer it from a folder, package, or "library versus application" - harm: a database-driver mislabeled "pure," a parser "impure," because of where it lives - check: review — return type decides; the lint behind CONST-P1 - example: - pure: "decide : Command -> Result" - effectful: "load : OrderId -> Effect — owns effects" -``` - ---- - -## Article III — Verification - -```yaml -rules: - - id: CONST-T1 - title: The Testing Trophy - gate: review - do: invest widest at Composition, anchored by Static Analysis, made honest by Properties - dont: build a Test Pyramid — it buries logic in I/O and leaves the untested middle - harm: logic buried in I/O; the untested middle - check: review — layer investment follows the trophy widths - layers: - - width: ████ - name: Static Analysis - scope: type checker, linter, dependency (DAG) checks - catches: Entire bug classes, zero maintenance - - width: ██████ - name: Property Tests - scope: Core invariants, ACL roundtrips, schema conformance - catches: What the system guarantees - - width: ██████████████ - name: Composition Tests - scope: Mocked I/O, real use cases, error paths - catches: Real workflows through the I/O sandwich - - width: ███ - name: Contract Tests - scope: transport schema, CLI output - catches: External interface conformance - - id: CONST-T2 - title: Properties Over Examples - gate: review - do: prove the pure core with property tests; the type is the generator - dont: cover the core with hand-picked example unit tests - harm: a green suite that tests only the cases you imagined, breaking on every refactor - check: review — property-test presence on the core - - id: CONST-T3 - title: Mutation Is the Measure - gate: mutation - do: gate the core at a perfect mutation score; kill a survivor with a sharper property or by deleting the dead branch it exploits - dont: - - reach the number by a suppression comment - - reach the number by narrowing the mutated set - - reach the number by lowering the gate - harm: a score certifying tests that notice nothing - check: mutation gate (break = 100); lint banning suppression and scope-narrowing - - id: CONST-T4 - title: Behavior Lives Where the Mutator Sees It - gate: lint - do: put any code that can be wrong (transform, check, branch) in a file the mutator covers - dont: place behavior in a declaration file (types, schemas, constant data), excluded from mutation - harm: a bug hidden behind a perfect score, in a file nothing mutates - check: lint — declaration files contain no behavior - - id: CONST-T5 - title: Pin Behavior Before You Rebuild - gate: review - do: before refactoring, cover the inputs the existing code accepts, with tests over real examples - dont: trust a mutation or property score to protect behavior you haven't pinned — they're blind to behavior you delete - harm: a rebuild silently drops a capability; the clean score certifies the regression - check: characterization tests over real fixtures; review -``` - ---- - -## Article IV — Organization - -```yaml -rules: - - id: CONST-N1 - title: Organized by What It Does - gate: review - do: organize by workflow and capability; keep code that changes together, together - dont: organize by what the system has (entities, technical layers) - harm: one change scattered across the tree - check: review — one change touches one capability subtree - - id: CONST-N2 - title: Names Scream the Domain - gate: lint - do: name files and folders for the job they do — a name must answer "of what?" - dont: - - use layer names (`core`, `shell`) - - use junk drawers (`util`, `service`, `manager`) - - use a suffix no rule keys on - harm: files no one can locate; meaningless buckets - check: filename lint — allowed suffixes; banned layer and junk-drawer names - - id: CONST-N3 - title: Fits in the Head - gate: review - do: give a module one responsibility; split it when a test needs elaborate setup (the signal it has several) - dont: accumulate unrelated concerns in one module - harm: modules no one can fully reason about; brittle, sprawling tests - check: review — fixture difficulty is the decomposition signal -``` - ---- - ## Article V — Conduct ```yaml diff --git a/README.md b/README.md index 83102de..a26b0e1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ One source of truth. Every consumer vendored. Zero drift. git fetch https://github.com/systemfsoftware/constitution.git main:refs/remotes/vendor/constitution git subtree add --prefix=vendor/constitution refs/remotes/vendor/constitution --squash -m "chore: vendor shared constitution" ln -s vendor/constitution/CONSTITUTION.md CONSTITUTION.md +ln -s vendor/constitution/CONSTITUTION-ARTICLES.md CONSTITUTION-ARTICLES.md ``` ## The Problem @@ -33,7 +34,9 @@ Design principles live in CONTRIBUTING.md, ARCHITECTURE.md, PR comments, Slack t ## The Solution -[`CONSTITUTION.md`](CONSTITUTION.md) is a single document — five articles, thirty-one rules — that every repo under System F Software vendors via `git subtree` and references via symlink. **Amend it once here, and every consumer picks up the new law on its next subtree pull.** No forks. No copies. No drift. +The law ships as two documents. [`CONSTITUTION.md`](CONSTITUTION.md) is **resident** — it goes in every agent's context and stays there, and it carries only what nothing announces and no mechanism catches: how to read a rule, how a principle is invoked and enforced, and how work is conducted. [`CONSTITUTION-ARTICLES.md`](CONSTITUTION-ARTICLES.md) is **retrieved** — Articles I–IV, the craft law, delivered when the work reaches the artifact each one governs. Every repo under System F Software vendors both via `git subtree` and references them via symlink. **Amend it once here, and every consumer picks up the new law on its next subtree pull.** No forks. No copies. No drift. + +The split is not filing. A rule whose harm fires before you would know to look it up has to be resident or it does not hold; a rule the work itself announces costs every unrelated task attention it never needed. Residency is bought with every token of every turn, so it is spent only where retrieval cannot reach. Stack neutrality is the load-bearing constraint: principles stay at the level of *"a state machine hidden in a record"* and *"mutation is the measure,"* not *"use this ESLint rule"* or *"this ORM."* Tools change every year; the laws do not. @@ -52,17 +55,20 @@ git fetch https://github.com/systemfsoftware/constitution.git main:refs/remotes/ git subtree add --prefix=vendor/constitution refs/remotes/vendor/constitution --squash \ -m "chore: vendor shared constitution" -# 3. Symlink it to the repo root +# 3. Symlink both to the repo root ln -s vendor/constitution/CONSTITUTION.md CONSTITUTION.md +ln -s vendor/constitution/CONSTITUTION-ARTICLES.md CONSTITUTION-ARTICLES.md ``` -Reference it from your agent harness (`AGENTS.md` or `CLAUDE.md`) so the bound rules are visible to every agent run: +Reference the resident half from your agent harness (`AGENTS.md` or `CLAUDE.md`) so it is in context on every run: ```markdown @CONSTITUTION.md ``` -You should see `vendor/constitution/CONSTITUTION.md` tracked in git, `CONSTITUTION.md` at the root as a symlink, and `git subtree pull` ready to refresh it. +Do **not** reference the articles the same way — that would make them resident and defeat the split. Deliver them on **write or edit** of a source file, never on read: an agent that greps, or works from a plan, never fires a read trigger. The trigger condition is the law's; the mechanism is your harness's — a path-scoped rule (`.claude/rules/*.md` with `paths:`), a pre-tool gate, or whatever your tooling exposes. Wire it in your own `AGENTS.md`, and if your harness has no such mechanism, say so there and fall back to a named situational read. + +You should see both files tracked under `vendor/constitution/`, both symlinked at the repo root, and `git subtree pull` ready to refresh them. ## Update a Consumer @@ -71,19 +77,21 @@ git subtree pull --prefix=vendor/constitution https://github.com/systemfsoftware -m "chore: update constitution" ``` -The symlink never changes — it always points at `vendor/constitution/`, so a pull just refreshes the content underneath. +The symlinks never change — they always point into `vendor/constitution/`, so a pull just refreshes the content underneath. ## Articles -| Article | Principle | -| --- | --- | -| **I — The Pure Core** | Decisions are pure; types come first; errors are variants; null is not a state; one path. | -| **II — The Boundary** | Functional core / imperative shell; effects are values; decode never cast; dependencies point inward. | -| **III — Verification** | The Testing Trophy; properties over examples; mutation is the measure. | -| **IV — Organization** | Organized by what it does; names scream the domain; fits in the head. | -| **V — Conduct** | Depth over expedience; challenge before you commit; subtract before you add. | +| Article | Delivery | Principle | +| --- | --- | --- | +| **I — The Pure Core** | retrieved | Decisions are pure; types come first; errors are variants; null is not a state; one path. | +| **II — The Boundary** | retrieved | Functional core / imperative shell; effects are values; decode never cast; dependencies point inward. | +| **III — Verification** | retrieved | The Testing Trophy; properties over examples; mutation is the measure. | +| **IV — Organization** | retrieved | Organized by what it does; names scream the domain; fits in the head. | +| **V — Conduct** | resident | Depth over expedience; challenge before you commit; subtract before you add. | -Each rule is a YAML block with `do`, `dont`, `harm`, and `gate` — machine-readable, agent-discoverable, and ready for property tests over the corpus. Read the full text: [`CONSTITUTION.md`](CONSTITUTION.md). +The Preamble and the Application block — how to read a rule, and how a principle is invoked, enforced, and resolved against another document — are resident alongside Article V. + +Each rule is a YAML block with `do`, `dont`, `harm`, `check`, and `gate` — machine-readable, agent-discoverable, and ready for property tests over the corpus. `pnpm test` validates both files as one corpus: ids are unique across it and citations resolve across it, so a rule lost in a move between the two fails the gate rather than passing quietly. ## Amendment @@ -91,6 +99,9 @@ The constitution is amendable by design. An amendment carries a written rational ## FAQ +**Q: Why two files instead of one?** +A: Because residency is not free. Every line of a resident document is paid on every turn of every agent in every consumer repo, whether or not the work touches it — and measured degradation from input length holds even when the model retrieves perfectly and the irrelevant tokens are masked out (Du et al., [arXiv 2510.05381](https://arxiv.org/abs/2510.05381)). Articles I–IV govern artifacts the work puts in front of you, and most of them are backed by a lint, type, or mutation gate; they can be fetched at the moment they apply. The Preamble, the Application block, and Article V cannot: nothing announces them, no mechanism catches them, and an agent about to conceal a bypass does not go looking for the rule against it. Those stay resident. Both halves are one corpus to `pnpm test`. + **Q: Why git subtree + symlink instead of a git submodule?** A: Submodules pin a commit and surface a detached `HEAD` to anyone cloning — bad for a document every contributor reads on day one. A subtree is just files, and the symlink makes the path stable so `AGENTS.md` can reference `@CONSTITUTION.md` once and never change. diff --git a/package.json b/package.json index 527cf0e..7b04cfb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "The supreme design law of System F Software — stack-neutral, vendored into every consumer repo via git subtree + symlink.", "main": "index.js", "scripts": { - "test": "python3 scripts/validate-constitution.py", + "test": "uv run --with pyyaml python3 scripts/validate-constitution.py", "prepare": "husky" }, "repository": { diff --git a/scripts/validate-constitution.py b/scripts/validate-constitution.py index 0658ac5..975022e 100755 --- a/scripts/validate-constitution.py +++ b/scripts/validate-constitution.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate CONSTITUTION.md against constitution-rule/v1. +"""Validate the constitution corpus against constitution-rule/v1. Gate for CONST-E1 applied reflexively: the constitution's own format must fail a command, not a cited clause. Validates every fenced ```yaml block against @@ -12,6 +12,12 @@ Without that comparison a green run means "no rule I happened to parse was malformed", which is not the claim the gate is making. +The corpus is two files — the resident law and the retrieved articles — and the +union is the unit every check runs over. Ids are unique across it and citations +resolve across it: CONST-S4 cites CONST-T5, which lives in the other file. Point +this at one file and the coverage comparison above still passes, on a third of +the rules, which is precisely the vacuous pass it exists to prevent. + There is no backwards compatibility and no retirement ledger. A deleted rule leaves its number vacant and a citation to it resolves to nothing, which is a loud failure and needs no gate. The one identifier defect that is NOT loud is an @@ -30,7 +36,7 @@ import yaml -PATH = "CONSTITUTION.md" +PATHS = ("CONSTITUTION.md", "CONSTITUTION-ARTICLES.md") ID_RE = re.compile(r"^CONST-[A-Z]\d+$") ID_IN_TEXT_RE = re.compile(r"^\s*- id:\s*(\S+)\s*$", re.M) @@ -64,20 +70,30 @@ def fail(errors): def check_against(rev, errors, live_titles): """A rename and a renumber produce near-identical diffs; only a comparison - across revisions tells them apart. + across revisions tells them apart. A corpus file absent at that revision + contributes no old titles: a rule that moved between files kept its id and + its title, which is no reassignment, and a file's own birth is not one + either. Only an empty union means the revision is wrong. """ - try: - old_text = subprocess.run( - ["git", "show", f"{rev}:{PATH}"], - capture_output=True, text=True, check=True, - ).stdout - except (subprocess.CalledProcessError, FileNotFoundError) as e: - errors.append(f"--against {rev}: cannot read {PATH} at that revision ({e})") - return + old_titles = {} + for p in PATHS: + try: + old_text = subprocess.run( + ["git", "show", f"{rev}:{p}"], + capture_output=True, text=True, check=True, + ).stdout + except FileNotFoundError as e: + errors.append(f"--against {rev}: git is not runnable ({e})") + return + except subprocess.CalledProcessError: + continue + old_titles.update(TITLE_IN_TEXT_RE.findall(old_text)) - old_titles = dict(TITLE_IN_TEXT_RE.findall(old_text)) if not old_titles: - errors.append(f"--against {rev}: no rules found at that revision — wrong rev, or the file moved") + errors.append( + f"--against {rev}: no rules found in any corpus file at that revision " + f"— wrong rev, or every file was renamed" + ) return for rid, old_title in old_titles.items(): @@ -94,19 +110,31 @@ def main(): help="git revision to check for reassigned ids") args = ap.parse_args() - text = open(PATH, encoding="utf-8").read() errors = [] - - blocks = re.findall(r"```yaml\n(.*?)```", text, re.S) + texts = {} + for p in PATHS: + try: + texts[p] = open(p, encoding="utf-8").read() + except FileNotFoundError: + fail([f"{p}: missing — the corpus is both files, and half a corpus " + f"scores exactly like a whole one"]) + text = "\n".join(texts.values()) + + blocks = [] + for p, t in texts.items(): + found = re.findall(r"```yaml\n(.*?)```", t, re.S) + if not found: + errors.append(f"{p}: no fenced yaml rule blocks found") + blocks.extend((p, b) for b in found) if not blocks: - fail(["no fenced yaml rule blocks found"]) + fail(["no fenced yaml rule blocks found in any corpus file"]) rules = [] - for i, block in enumerate(blocks): + for i, (p, block) in enumerate(blocks): try: doc = yaml.safe_load(block) except yaml.YAMLError as e: - errors.append(f"block {i}: YAML parse error: {e}") + errors.append(f"{p} block {i}: YAML parse error: {e}") continue rules.extend(doc.get("rules", [])) @@ -152,9 +180,10 @@ def main(): if ex is not None and not (isinstance(ex, dict) and all(isinstance(v, str) for v in ex.values())): errors.append(f"{rid}: 'example' must be a map of strings") - for cited in sorted(set(CITE_RE.findall(text))): - if cited not in seen: - errors.append(f"dangling citation: '{cited}' is cited in {PATH} but names no rule") + for p, t in texts.items(): + for cited in sorted(set(CITE_RE.findall(t))): + if cited not in seen: + errors.append(f"dangling citation: '{cited}' is cited in {p} but names no rule") if args.against: check_against(args.against, errors, dict(TITLE_IN_TEXT_RE.findall(text))) @@ -162,7 +191,8 @@ def main(): if errors: fail(errors) suffix = f"; no id reassigned since {args.against}" if args.against else "" - print(f"valid: {len(rules)} rules across {len(blocks)} yaml blocks, {len(FAMILIES)} families{suffix}") + print(f"valid: {len(rules)} rules across {len(blocks)} yaml blocks in " + f"{len(texts)} files, {len(FAMILIES)} families{suffix}") if __name__ == "__main__": From f7aa24c7947f8337fd183387b1ec0e85b365dfd4 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 18 Aug 2026 03:37:39 +0000 Subject: [PATCH 2/4] refactor(scripts): keep every corpus diagnostic attributable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects the split introduced into the validator's failure paths, all found by review rather than by a run — every one of them only shows up in output that is already failing, which is exactly where nobody looks twice. - an empty-fence report for each file was appended to `errors` and then discarded: `fail()` was called with a fresh list, so the two file-specific lines vanished on the one path where attribution matters most - block indices were corpus-wide while the message named a file, so a YAML error in the articles file's third block read `block 5`. Now file-local: the same fixture reads `CONSTITUTION-ARTICLES.md block 2` - dangling citations were sorted per file, so ordering depended on iteration and an id cited in both files reported twice with no dedup. Now one sorted corpus-wide pass, one line per file that cites it, in PATHS order - coverage still said "declared in the file" Dropped the joined `text` copy with them. `declared_ids` and the `--against` title map now fold per file like everything beside them, which removes a second in-memory copy of the corpus and the cross-file regex boundary it created — `TITLE_IN_TEXT_RE` spans two adjacent lines, so a join could match across a file seam. Verified against known-good plus four known-bad fixtures: absent half, duplicated id, dropped-but-cited rule, and a YAML error in the second file to pin the block index. Skipped one reviewer nit: swapping `open().read()` for `pathlib.Path.read_text` buys an import and no behaviour. --- scripts/validate-constitution.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/scripts/validate-constitution.py b/scripts/validate-constitution.py index 975022e..673869a 100755 --- a/scripts/validate-constitution.py +++ b/scripts/validate-constitution.py @@ -118,32 +118,32 @@ def main(): except FileNotFoundError: fail([f"{p}: missing — the corpus is both files, and half a corpus " f"scores exactly like a whole one"]) - text = "\n".join(texts.values()) blocks = [] for p, t in texts.items(): found = re.findall(r"```yaml\n(.*?)```", t, re.S) if not found: errors.append(f"{p}: no fenced yaml rule blocks found") - blocks.extend((p, b) for b in found) + blocks.extend((p, j, b) for j, b in enumerate(found)) if not blocks: - fail(["no fenced yaml rule blocks found in any corpus file"]) + errors.append("no fenced yaml rule blocks found in any corpus file") + fail(errors) rules = [] - for i, (p, block) in enumerate(blocks): + for p, j, block in blocks: try: doc = yaml.safe_load(block) except yaml.YAMLError as e: - errors.append(f"{p} block {i}: YAML parse error: {e}") + errors.append(f"{p} block {j}: YAML parse error: {e}") continue rules.extend(doc.get("rules", [])) parsed_ids = [str(r.get("id")) for r in rules] - declared_ids = ID_IN_TEXT_RE.findall(text) + declared_ids = [i for t in texts.values() for i in ID_IN_TEXT_RE.findall(t)] uncovered = [i for i in declared_ids if i not in parsed_ids] if uncovered: errors.append( - f"{len(uncovered)} rule(s) declared in the file but never parsed " + f"{len(uncovered)} rule(s) declared in the corpus but never parsed " f"into a yaml block: {uncovered} — check for an unterminated ```yaml fence" ) @@ -180,13 +180,19 @@ def main(): if ex is not None and not (isinstance(ex, dict) and all(isinstance(v, str) for v in ex.values())): errors.append(f"{rid}: 'example' must be a map of strings") - for p, t in texts.items(): - for cited in sorted(set(CITE_RE.findall(t))): - if cited not in seen: + cites = {p: set(CITE_RE.findall(t)) for p, t in texts.items()} + for cited in sorted(set().union(*cites.values())): + if cited in seen: + continue + for p in PATHS: + if cited in cites.get(p, ()): errors.append(f"dangling citation: '{cited}' is cited in {p} but names no rule") if args.against: - check_against(args.against, errors, dict(TITLE_IN_TEXT_RE.findall(text))) + live_titles = {} + for t in texts.values(): + live_titles.update(TITLE_IN_TEXT_RE.findall(t)) + check_against(args.against, errors, live_titles) if errors: fail(errors) From dd21bfe14db31298c305f5fd11bb4d1079e44418 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 18 Aug 2026 03:54:17 +0000 Subject: [PATCH 3/4] fix(scripts): fail a corpus file that exists and declares nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the split's own claim was false, and the gate's hard-fail covered only half the shape it names. `CONSTITUTION.md` emptied to a preamble plus two `placeholder: true` blocks printed `valid: 21 rules across 6 yaml blocks in 2 files` and exited 0. The missing-file arm caught absence; nothing caught presence-without-rules, which scores identically. Every path in `PATHS` must now contribute at least one parseable rule, and the same fixture fails naming the file. `AGENTS.md` and `README.md` claimed `pnpm test` fails "a rule dropped in a move". Measured: dropping uncited `CONST-N3` exits 0 at `valid: 33 rules`, and 28 of 34 rules have no inbound citation, so that was the common case rather than an edge. Both documents now state the boundary they actually have — left in both fails on the id, present-but-empty fails, dropped-and-cited fails, dropped-and-uncited is reported. Reported, not failed, because failing it would fire on every legitimate deletion, which `AGENTS.md`'s own id policy calls correct ("the number stays vacant, forever"). `--against` now names on the success line every id vacated since the revision, and every corpus file it could not compare because the file did not exist there. That second clause closes a hole this branch opened: tolerating an absent file let a corpus file renamed in the same commit that retitles a rule pass with "no id reassigned" — the single-file version failed loudly there. It stays green, but no longer silent. `AGENTS.md` § Choosing the file gained the precedence the seam was missing. Both branches fit a conduct rule — an artifact exists AND the violator is the one who would not fetch the document — and the tie-breaker lived only in README, which no agent loads. Resident wins: "the work announces it" means an artifact a complying agent has in hand. Declared per CONST-W3: commit 1 ships the validator change together with the corpus move it judges, which CONST-E4 tells you not to do. Validator-first is uncommittable here — `.husky/pre-commit` runs `pnpm test`, and the widened validator hard-fails on a `CONSTITUTION-ARTICLES.md` that the next commit creates, so the ideal sequence requires `--no-verify`, which `AGENTS.md` forbids. The direction is the opposite of E4's harm: the gate went from validating 13 of 34 rules to 34, evidenced by fixtures rather than asserted. Fixtures re-run: known-good; resident file declaring no rule (fails); uncited rule dropped (green, vacated id named); renamed file plus retitled rule (green, uncompared file named); absent half; duplicate id; dropped-but-cited rule; broken fence in the second file. --- AGENTS.md | 6 +++++- README.md | 2 +- scripts/validate-constitution.py | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fb0d73..39576eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,11 @@ Before making changes: A rule goes in `CONSTITUTION.md` when its harm fires before you would know to look it up: nothing announces it, and no mechanism catches it after the fact. It goes in `CONSTITUTION-ARTICLES.md` when the work announces it — an artifact is in front of you (a domain type, a decision function, a boundary object, a test, a module's name) and a trigger can fire on writing it. -The partition is not gated, deliberately. The obvious key — resident means `gate: review` — is a field the rule's own author writes, and it is false anyway: `CONST-B1`, `CONST-T2` and `CONST-N3` are review-gated and artifact-announced. Check: review — which of the two conditions above the rule meets. What *is* gated is that the union stays whole; `pnpm test` reads both files, so a rule dropped in a move fails coverage and a rule left in both fails on its id. +Where both branches fit, resident wins. "The work announces it" means an artifact a *complying* agent can be expected to have in hand — not a trigger that exists in principle. That is why the S and W families stay resident even though the family table describes them by their artifacts: the agent who conceals a bypass, shrinks scope, or copies the neighbouring file is precisely the agent who does not fetch the document, so a trigger that fires on the artifact never reaches the reader who needed the rule. + +The partition is not gated, deliberately. The obvious key — resident means `gate: review` — is a field the rule's own author writes, and it is false anyway: `CONST-B1`, `CONST-T2` and `CONST-N3` are review-gated and artifact-announced. Check: review — which of the two conditions above the rule meets, and whether the precedence applies. + +What `pnpm test` gates about the two files is narrower than "the union stays whole", and the gap is stated because it was measured: a rule left in **both** files fails on its duplicate id; a corpus file that exists but declares no rule fails; a dropped rule fails while any citation to it survives. A dropped rule that nobody cites does **not** fail — 28 of 34 rules have no inbound citation — so `pnpm test --against ` reports it instead: it names every id vacated since that revision, and every corpus file it could not compare because the file did not exist there. Read those two clauses on the success line; a green run that silently measured less is the defect this gate exists to prevent, and reporting is how it stays visible without failing every legitimate deletion. ### Writing a rule diff --git a/README.md b/README.md index a26b0e1..22c2ae9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The symlinks never change — they always point into `vendor/constitution/`, so The Preamble and the Application block — how to read a rule, and how a principle is invoked, enforced, and resolved against another document — are resident alongside Article V. -Each rule is a YAML block with `do`, `dont`, `harm`, `check`, and `gate` — machine-readable, agent-discoverable, and ready for property tests over the corpus. `pnpm test` validates both files as one corpus: ids are unique across it and citations resolve across it, so a rule lost in a move between the two fails the gate rather than passing quietly. +Each rule is a YAML block with `do`, `dont`, `harm`, `check`, and `gate` — machine-readable, agent-discoverable, and ready for property tests over the corpus. `pnpm test` validates both files as one corpus: ids are unique across it, citations resolve across it, and a file that exists but declares no rule fails rather than scoring like a whole one. A rule dropped in a move fails while any citation to it survives; when nothing cites it, `pnpm test --against ` names the vacated id on the success line instead, because failing every legitimate deletion would be worse than reporting one. ## Amendment diff --git a/scripts/validate-constitution.py b/scripts/validate-constitution.py index 673869a..b90ebdb 100755 --- a/scripts/validate-constitution.py +++ b/scripts/validate-constitution.py @@ -16,7 +16,10 @@ union is the unit every check runs over. Ids are unique across it and citations resolve across it: CONST-S4 cites CONST-T5, which lives in the other file. Point this at one file and the coverage comparison above still passes, on a third of -the rules, which is precisely the vacuous pass it exists to prevent. +the rules, which is precisely the vacuous pass it exists to prevent. A file that +is merely absent is not the only shape of that pass: a file present and parsing +but declaring no rule scores identically, so every path in PATHS must contribute +at least one rule of its own. There is no backwards compatibility and no retirement ledger. A deleted rule leaves its number vacant and a citation to it resolves to nothing, which is a From 734e1d6b37a9da2b83247b38c5dec9caea5f890a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 18 Aug 2026 03:58:58 +0000 Subject: [PATCH 4/4] docs(solutions): capture the vacuous pass A gate can go green because it stopped looking. Three shapes found this session, each by a known-bad fixture rather than by reading the code: an input set narrowed to one file while the corpus grew to two; a path present and parsing but declaring nothing; and a cross-revision arm whose tolerance for a newly created path also swallowed a renamed one. The rules that fall out: a missing input is a hard failure never a smaller pass, presence is not contribution, and what cannot be failed must be reported on the success line. Plus the trap: the tempting fix is a pinned expected count, which is a value the author supplies, so the check never runs on the case it exists to catch. Cited by symbol and role, no repo paths or line numbers, so it does not rot when the validator moves. --- .../the-vacuous-pass-gate-input-sets.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/solutions/architecture-patterns/the-vacuous-pass-gate-input-sets.md diff --git a/docs/solutions/architecture-patterns/the-vacuous-pass-gate-input-sets.md b/docs/solutions/architecture-patterns/the-vacuous-pass-gate-input-sets.md new file mode 100644 index 0000000..9796243 --- /dev/null +++ b/docs/solutions/architecture-patterns/the-vacuous-pass-gate-input-sets.md @@ -0,0 +1,152 @@ +--- +title: A gate can go green because it stopped looking +date: 2026-08-18 +category: architecture-patterns +module: constitution corpus validator +problem_type: architecture_pattern +component: tooling +severity: high +applies_when: + - A gate's input set is named in the gate rather than derived from the tree + - A single-artifact check is widened to cover several artifacts + - A merge-blocking check reports a count it computed itself +tags: [gate-design, vacuous-pass, verification, fail-closed, known-bad-fixture] +--- + +# A gate can go green because it stopped looking + +## Context + +The constitution's format gate validated one markdown file whose path was a module-level +constant. The law was then split across two files. The gate's logic was untouched and +entirely correct — it parsed, it compared declared rule ids against parsed rule ids, it +found no discrepancy, it exited 0. It was reporting on a third of the rules. + +Nothing in the output distinguished that from a healthy corpus. The same run over the +whole corpus and the run over a third of it print the same shape, differing only in a +number nobody had a baseline for. + +Two further shapes of the same defect surfaced during review of the fix, both after the +input set had been widened to a tuple of paths: + +- A path present in the tuple but **absent from disk** was caught by a hard failure. A + path present, parsing cleanly, and declaring **zero rules** was not — emptying the + resident half to a preamble plus two `placeholder: true` blocks printed a valid line + with a smaller count and exited 0. +- The cross-revision arm tolerated a path that did not exist at the older revision, + because a newly created file legitimately has nothing to compare. That tolerance also + swallowed a path **renamed** in the same commit that re-scoped a rule: the older + revision had no such path, so the rule's retitle went uncompared and the run reported + no reassignment. + +## Guidance + +**Assert the corpus, not only the contents.** A check over a subset is +indistinguishable from a check over the whole unless the gate says which inputs it +measured. Three rules follow. + +1. **A missing input is a hard failure, never a smaller pass.** Absence of an expected + input is a defect in the gate's own configuration, and it must exit non-zero rather + than validate what remains. + +2. **Presence is not contribution.** An input that resolves, parses, and yields nothing + scores exactly like one that yields its half. Require every declared input to + contribute at least one unit of the thing being validated. This is a recomputation + from the bytes — *did this file produce a rule?* — not a number the author supplies. + +3. **What cannot be failed must be reported.** Some gaps are legal and failing them + would fire on correct work: a deliberate deletion, a genuinely new input with no + history. Name them on the success line — which inputs were not compared, which + identifiers vacated — so the reader sees the reduced coverage instead of inferring + full coverage from a green exit. + +**Never key a gate on a value its own author supplies.** The tempting fix for shape 1 is +a pinned expected count. That is a field the author writes, so the gate never runs on the +case it exists to catch. Prefer a key the gate recomputes: a digest over current bytes, a +compiler verdict, a re-derivation from the tree. + +**Prove the widening with a known-bad fixture.** A gate that has only ever been run +against a healthy input has demonstrated that it can print a success line. Run it against +an input that must fail, and read the failure text. Each defect above was found by a +fixture, not by reasoning about the code. + +## Why This Matters + +The failure is silent by construction and lands in the one place nobody reads twice: +output that already says everything is fine. Downstream, the green result is then cited as +evidence the invariant holds — so the gate does not merely fail to catch the defect, it +actively certifies its absence. + +The documentation compounds the harm. Prose asserting a guarantee the gate does not +implement ("a rule dropped in a move fails the gate") trains a maintainer to skip the +manual review the gate is not doing. Two independent reviewers caught that sentence here; +it had been written in the same change that widened the gate, by the author most convinced +the gate was now sound. + +## When to Apply + +- Widening any check from one artifact to several — the moment the input set becomes a + collection, it becomes a thing that can silently shrink. +- Any gate that prints a count it computed itself, with no baseline the reader can check. +- Any check whose failure path is reachable only by a state the repository never normally + reaches; that path has almost certainly never run. +- Reviewing a claim about what a gate enforces. Run the fixture; do not read the code and + agree with it. + +## Examples + +Before — a single named input. Correct, and silently correct about a third of the corpus +once the corpus grew: + +```python +PATH = "ONE_FILE.md" +text = open(PATH, encoding="utf-8").read() +# ... one file's worth of checking, exit 0 +``` + +After — the input set is a collection, absence is fatal, and emptiness is fatal: + +```python +PATHS = ("RESIDENT.md", "RETRIEVED.md") + +for p in PATHS: + try: + texts[p] = open(p, encoding="utf-8").read() + except FileNotFoundError: + fail([f"{p}: missing — half a corpus scores exactly like a whole one"]) + +# ... after parsing, every declared input must have produced something +for p in PATHS: + if p not in contributors: + errors.append(f"{p}: parses but declares no rule") +``` + +And what cannot be failed is stated rather than omitted: + +``` +valid: 34 rules across 6 yaml blocks in 2 files, 9 families; + no id reassigned since ; + not compared, absent at : RETRIEVED.md; + 1 id(s) vacated since : CONST-N3 +``` + +The fixture battery that found all of it — each must fail, and the failure text is the +artifact worth keeping: + +| Fixture | Required result | +|---|---| +| one corpus path absent from disk | fail, naming the path | +| one corpus path present but declaring nothing | fail, naming the path | +| an identifier duplicated across two paths | fail on the duplicate | +| a rule deleted while a citation to it survives | fail on the dangling citation | +| a malformed fence in the second path | fail, naming that path and a path-local block index | +| a rule deleted with nothing citing it | pass, and name the vacated identifier | +| a path renamed alongside a rule retitle | pass, and name the uncompared path | + +## Related + +- The gate discussed here is the constitution corpus validator invoked by the repository's + `test` script; its module docstring carries the same argument at the point of use. +- `CONST-E1` (Prefer the Gate) and `CONST-E3` (A Gate Earns Its Place) are the rules that + make a gate the final word and price its false-positive budget; this learning is the + counterweight — a gate that cannot fail is not enforcement, it is a certificate.