Disciplined AI Collaboration
Constraints, checks and skepticism for building software with AI.
Build
A1Detect, log, fix
From the outside the work is a loop with three steps. Run the tools with their fixers on. Hand the AI the findings the fixers could not close. Let it fix what the findings name. Repeat until the tools are quiet. The loop closes because the tools speak to the AI in a typed vocabulary, and the report on disk is the state of the work rather than a rendering of one run.
A1.1Detect, heal, report, fix
An AI refactoring against a specific finding tends to do better than an AI generating from an abstract principle. Telling the AI about your principles and hoping for compliance does not hold across a session.
You explain the architecture at length, the AI agrees, and the next file it writes breaks it in a way the explanation did not anticipate. A finding is small and specific, and a model repairs small and specific things well. A principle is large and abstract, and a model applies it inconsistently.
Direct the AI with findings, never with principles. Feed the AI the finding, not the rule. Say which file, which line, what the tool expected, what it found, and the one action that would close it. Let it repair that and nothing else. Run the tools again once the tree has changed, and read the report to learn what is still open rather than re-running the tools to find out.
Compare a session driven by findings with one driven by explanation. Count the fixes that stuck. The findings session wins, and if it does not, the findings are not specific enough.
A finding whose only repair the toolchain refuses to perform is withdrawn or exempted with that refusal as the stated reason. A report nobody can drain trains every reader to discount the colour, and the cost lands on the findings beside it.
Healing comes before reporting. A violation whose remediation has exactly one correct answer is repaired in the same run that caught it, without being asked: a missing type the grammar computes, a form the registry records, a name whose only legal spelling is derivable. The flag disables healing and never enables it, because an opt-in fix flag inverts the rule and turns a computed repair into a queue. A fix is applied, re-validated and converges, and one that fails its own check is not a fix. What reaches the AI is the residue: the findings that need judgement, which concern a file is, whether two roles split, what a duplicated fact's source should be.
The error log is the communication channel, so it is typed. A finding carries the id of the check that fired, the path, the locus inside the file, the trail of what the check resolved on the way, the observed value, the derived expected value where one exists, a remediation as an action with real operands, and whether it healed. Prose in a finding is a defect, because the consumer is a reasoning agent that must not re-derive the analysis the check already performed. A sentence describing a rename is a description; the action and its two operands are a contract the agent can execute.
The report is the state, not the run. Every check writes its own report with its derivations, not only its verdict, and one aggregate is written on every exit path so a present report is never mistaken for a passing one. Outstanding work is answered by reading the report from disk. Re-running a tool to filter its output differently is the common form of waste and does not look like re-verification, because the question got sharper while nothing in the tree changed. A run is warranted when the tree has changed, and then the report is history and the run is the measurement.
flowchart TB
detect["Detect · the one command, fixers on"]
heal["Heal · every finding with one correct answer, in the same run"]
report["Report · the findings the fixers left, as data on disk"]
fix["Fix · the AI, one finding at a time, from the report"]
detect --> heal --> report --> fix
fix -- until the report is empty --> detectA finding the AI can act onengine/registries/route.registry.ts:41 rule literal-in-lookup locus the first argument of the lookup call trail resolved the callee verb, matched the lookup form, read the argument expected an imported constant as the lookup key found the string literal "home" fix replace the argument with the id from the ids module: HOME_PAGE healed no
B1The gate holds the line
A rule without a check is a wish. The check ships with the rule, in the same change, or the rule does not exist. This is the one rule every other rule depends on, and its intent is not strictness for its own sake: it is that a rule held by a check costs the same to hold on the thousandth change as on the first, while a rule held by attention costs more every time.
B1.1The check is the rule
The gate holds the line, not discipline. A rule holds only when a check enforces it. Convention does not survive contact with a model or with a tired person.
The team agrees on a rule, someone tells the AI about it, and a month later half the tree follows it because nothing ever refused the other half. Convention re-decides a rule at every use, and the model has no reason to decide it the same way twice.
Ship every rule with the check that enforces it. Write the check the moment you introduce the pattern, and make it catch the pattern's bypasses too. When a violation slips past it, extend the check before you touch the content that slipped, so the gate strengthens before the cleanup. Never weaken a check, never exclude a case to go green, and never add a tier softer than failure. Where a pattern cannot be gated statically, raise that as a question rather than treating it as permission.
Break the rule on purpose. The check must report it with the message you expected. Restore the file. A check nobody has broken on purpose has never fired at all.
A pattern no static check can catch is a question for the person who owns the work, not a licence to skip the check. The honest answer to that question is a rule declared unobservable, with the evidence a check would need written beside it.
The intent behind gating every pattern is that the check is the fix and the content edit is the cleanup. When a bypass is spotted, taken or confirmed, the first action is hardening the check that missed it, and only then repairing what slipped through. That order matters because a repaired instance with an unrepaired check is the same defect waiting for its next instance, and the party who found it has already spent the attention that would find it again. A caught duplicate, a caught escape hatch and a better approach discovered mid-task are all the same trigger: the affected rule absorbs the shape.
Every check returns pass or fail. There is no warning tier and no advisory level, because a middle tier lets a run terminate as successful while a failure is still open, which is the outcome a binary verdict exists to prevent. Severity survives as repair ordering among failures, never as a softer verdict. A check that would warn is promoted or deleted. The same refusal covers exclusions: a caught finding is resolved by fixing the code or by proving the finding tests a false premise, which deletes it, and never by widening an exemption or disabling the rule for one file.
flowchart TB
pattern["A new pattern enters the tree"]
ships["The check ships in the same change, with a planted violation"]
slips["A violation slips past the check"]
appended["The check is extended first, then the content is repaired"]
inconvenient["A finding is inconvenient"]
stays["It stays a finding · no warn tier, no exclusion"]
disagree["Two checks disagree on one line"]
derived["Both stay on · the form that satisfies both is derived"]
uncheckable["A pattern no static check can catch"]
surfaced["Surfaced as a question, never used as licence"]
pattern --> ships
slips --> appended
inconvenient --> stays
disagree --> derived
uncheckable --> surfaced
ships ~~~ slips
appended ~~~ inconvenient
stays ~~~ disagree
derived ~~~ uncheckableC1The check comes first
The check ships with the pattern, in the same change, and it is proven before it is trusted. A new project arrives with its gate already green, and you write the first line of application code against a check that already runs. The order is check, proof, then code, and each half of the proof catches a failure the other cannot.
C1.1Check, prove, then build
The check precedes the code, and nobody trusts a check until it has caught something on purpose and let something through on purpose. A check written after the code tends to describe the code rather than the rule.
The check comes last, passes on the first run, and nobody ever learns that it would have passed anything. A check written after the code is shaped by the code it was written for, and it passes that code by construction.
Order every change as check, proof of firing, positive control, then code. Write the check first. Break it on purpose and watch it fire with the message you expected. Then run it over the real population and confirm at least one real member passes for the right reason. Restore the planted file, and only then write the code the check will hold. After narrowing a check for precision, run the case that motivated it again.
Find the change where each check first fired and the member it first cleared. A check with no such moments has never shown that it works.
A check every member satisfies for free is a field that always says the same thing, and its own greenness becomes the evidence that what it measures is working. Where nothing can disagree with a check, it is held with the forgone property written down rather than shipped weaker.
The two halves of the proof answer two different questions. A planted violation shows the check can reject, and a check nobody has seen fail is indistinguishable from one that cannot. A real member passing shows the check can discriminate, and a check whose every member fails has only been shown to reject: its first green is indistinguishable from a scope that stopped reaching. Where no member can pass yet because the correct shape does not exist in the tree, the population is built before the check rather than the check being trusted to describe it.
Narrowing is where a correct check silently loses its subject. A rule is authored against a case and then scoped for precision, every refinement is judged on the false positives it removes, and nobody re-runs the true positive, so a scope that excludes the motivating case reads exactly like a scope that got tighter. The cheapest scoping is usually the harmful one, because it keys on the property the correct members share rather than on the property the defect has. Re-running the motivating case after every scoping costs a sentence, and it is the only step that separates a check that became precise from one that became blind.
flowchart TB
write["Write the check"]
plant["Plant a violation"]
fires{"Does it report the expected message?"}
real["Run it over the real population"]
passes{"Does at least one real member pass for the right reason?"}
restore["Restore the planted file"]
code["Write the code the check holds"]
write --> plant --> fires
fires -- no --> write
fires -- yes --> real --> passes
passes -- no, everything fails --> write
passes -- yes --> restore --> codeD1A check matches a shape
A check enforces the shape of an anti-pattern, never a provider, a package, a filename or a threshold. The check and its message transfer: the same rule catches every future occurrence of the same shape, whatever library or symbol it happens to involve. This is how a small number of checks governs a growing tree without growing with it.
D1.1Mechanism general, data specific
A check matches a shape, its instances are data, and its message states the fix generically. The obvious check names the thing that went wrong, and the thing that went wrong is one instance of a shape that will recur under other names.
A rule bans one library's direct import by name, a second library with the same hazard arrives, and the rule says nothing because it never knew what it was protecting. A check that names an instance is correct on the case that motivated it and silent on the next case, which is the one it was written to prevent.
Gate constructs, never literals. Write the check for the class. Detect a construct in a syntax tree, a token sequence or a structural relation, never a name. Keep the instances as data in a registry the check reads, seeded only with instances verified to have the shape. Write the message as the shape and its one fix, in terms a reader on a different library understands unchanged. Make a rule active by dropping a file, and let the index that registers it be re-derived from disk.
Read a check's message with the vendor, the path and the symbol removed. If it still says what is wrong and how to fix it, the check matches a shape. If it says nothing, the check matched an instance.
A shape whose classifying property is semantic rather than structural may carry a registry of classified instances. The split is strict: the mechanism reads the registry, the registry is the only place a specific name appears, and the message stays general whatever the registry holds.
Two properties decide whether a shape is gateable, and both must hold. Detection is deterministic: the bad shape is recognisable by structural analysis, an import edge, a manifest field, a path, never by a flaky runtime probe. Where the only way to catch a thing is to observe non-determinism at runtime, the work is to reduce it to the static shape that causes the non-determinism and gate that. Remediation is deterministic: there is one correct fix, statable without reference to any case. Where both hold the shape is gated. Where neither holds, it is surfaced as a question rather than hand-waved.
A check is placed by what it needs to see. A rule over one file's syntax tree is the lightest: a file dropped flat into the rule host, discovered by a text match on its contract, active at failure severity with no core file edited. A rule over relations between files reads a closure graph a fixer stage wrote first, and it fails closed when the graph is missing, so the stage order is load-bearing rather than incidental. A rule over a folder rather than a file walks from the root, and it needs an anchor, because a finding is reported against a file it visits: a placement finding attaches to a file inside the offending folder, and a declaration pointing at something absent attaches to the manifest that declared it. A shape that spans the whole workspace is a wrapper named in the one config, and a shape that spans every language is a catalogued rule registered against a canonical concept. A whole-tree check that is not per file is a validator stage, and a rewriter is a codemod that verifies its own output by re-parsing before it writes.
The mechanism that discovers rules is itself shape-based, and that is what lets the core stay untouched. A rule becomes active by declaring a contract a registry discovers; if any core file must learn its name, the design is wrong. The contract is checked by the gate against itself: a rule missing a message, declaring a severity of its own, or reaching for an untyped escape fails at lint rather than at load. The mechanism enforcing every other rule is the one most able to decay silently, because nothing else watches it, so the rule host passes through the gate it enforces.
Matching is exact rather than approximate. A check is derived, never guessed: a check that is usually right is wrong, because its misses are invisible and its false accusations punish the party who did the right thing. Authored tooling matches by tree traversal, token comparison or exact string, never by a pattern language that hides the grammar it implements. A hand-written scanner separates use from mention, so a detector never matches its own detection strings inside a report about them, and it tests the call form rather than a list of names, because a name list is a check naming instances and reopens the moment one more name exists.
The rule contract is small and typed, and its typing is what lets the registry discover it and the gate check it. A rule declares its kind, a description, an options schema and its messages, and it exports a function that returns a listener over the syntax tree. The message ids are a closed union, so a report naming an undeclared message fails to compile and a declared message nobody reports is a finding against the rule. The rule object is exported directly rather than bound to a name, because the filename is the rule's identity and a second name would be a second fact that has to agree. Severity is never declared by a rule, because there is exactly one and the generator assigns it. What a rule emits is typed too: a finding carries the rule that fired, the path, the locus down to the member, the resolution trail, the observed value, the derived expectation where one exists, a remediation as an action with resolved operands, and whether it healed. Prose in a finding is a defect, because the consumer is a reasoning agent that must not re-derive the analysis the check already performed.
A rule contract · discovered by shape, typed over its messagesexport interface RuleContract<MessageId extends string> { readonly meta: { readonly type: "problem"; readonly docs: { readonly description: string }; readonly schema: readonly []; readonly messages: Readonly<Record<MessageId, string>>; }; readonly create: (context: RuleContext<MessageId>) => RuleListener; } export default { meta: { type: "problem", docs: { description: "a single-instance resource is reached through its one owner" }, schema: [], messages: { directReach: "A single-instance resource must be reached through its owner, which serialises access; route the call through the owner's API.", }, }, create(context) { return listener({ callExpression(view, node) { if (reachesSharedInstance(view)) { context.report({ node, messageId: "directReach" }); } }, }); }, } satisfies RuleContract<"directReach">;
A finding · every field computed, the remediation typedexport type Severity = "error"; export interface Remediation<Action extends string> { readonly action: Action; readonly operands: Readonly<Record<string, string>>; } export interface Finding<Action extends string = string> { readonly rule: string; readonly path: string; readonly locus: { readonly line: number; readonly column: number; readonly member?: string }; readonly trail: readonly string[]; readonly actual: string; readonly expected: string | null; readonly remediation: Remediation<Action>; readonly healed: boolean; readonly severity: Severity; }
flowchart TB
subgraph mechanism["The mechanism · provider-agnostic"]
detect["Detect the shape · a tree pattern, a token sequence, a structural relation"]
message["State the shape and its one fix, naming no instance"]
end
subgraph registry["The registry · data"]
instances["Verified instances of the shape, one line each"]
end
detect -- reads --> instances
next["The next instance"]
next -- one registry line, no new check --> instancesflowchart TB
shape["A shape to gate"]
where{"What does the check need to see?"}
perfile["One file's syntax tree · a per-file rule dropped into the rule host"]
closure["Relations across files · a rule that reads the closure graph a fixer wrote first"]
folder["A folder rather than a file · a rule that walks from the root and anchors its finding"]
workspace["The whole workspace · a wrapper named in the one config"]
ecosystem["Every ecosystem · a catalogued rule registered against a canonical concept"]
pipeline["A whole-tree pass or a rewrite · a validator or a codemod as a stage"]
shape --> where
where --> perfile
where --> closure
where --> folder
where --> workspace
where --> ecosystem
where --> pipelineflowchart TB
file["A rule file dropped into the host"]
discover["Discovered by shape · a text match on its contract"]
index["The index is re-derived from disk"]
active["Active at failure severity, no core file edited"]
self["The rule host passes through the gate it enforces"]
file --> discover --> index --> active --> self
self -. a malformed rule fails at lint, never at load .-> fileE1Tools live in the tree
Every manual step is a failure of automation, so a capability gap is closed by a tool that lives in the tree with a command surface of its own. Which tools exist, what kind each one is, and why it exists follow from the method rather than from preference: a tool exists where a check needs an input, a person would otherwise repeat a step, or an eye would otherwise stand in for a measurement.
E1.1Every manual step is a missing tool
A capability gap is closed by a tool in the tree, never by a manual loop or a workaround. The cheapest response to a missing capability is to do the thing by hand, and by hand is where the variance lives.
A ten-minute manual procedure runs weekly for a year, differently each time, and the one week it is skipped is the week it mattered. A manual loop is faster the first time and slower every time after, and nothing records how it was done.
Build the missing tool, and route it to the existing owner first. Close a gap by authoring a tool with a declared command surface that lives in the tree, and route the capability to whatever already owns its concern before writing a second one. Give the tool a contract a reader sees before any effect: its flags declared, an undeclared flag refused, its help printed on request. Let a tool that allocates or rewrites values run in preview and show its diff before it applies. Where a mechanism requires a write to a surface, make that surface tool-writable before the requirement exists.
List every step you performed by hand this week. Each one is either a tool that does not exist yet or a tool that exists and was not used.
A one-off script belongs in a scratch location and is promoted into the tree the moment its reusability appears. The discipline is for anything a second run will need, and a scratch tool that gets a second run has already crossed that line.
The tools fall into kinds by what they stand in for. A generator stands in for a fact that would otherwise be typed by hand: an index, a catalogue, a document derived from a manifest, a rendered diagram. A validator stands in for a review that would otherwise be performed from memory: discovery of every route, leaks of internal names into public copy, the resolution of every reference a document makes. A fixer stands in for a repair that has one correct answer. A probe stands in for an eye. Each kind is reached through the one chain, because a tool reachable only by its own command is convention whatever its quality.
The probe is the kind worth dwelling on, because the eye is the measurement most often trusted and least often right. A visual, numeric or timing defect is diagnosed by adding a probe that writes a value you can read before changing anything, and by binary-searching the pipeline against a known-good control. Adjusting values across repeated runs proves nothing. So a look tool returns what the eye cannot: beside the screenshot, the console log the page produced while rendering, the layout the engine computed, and the markup that was actually in the tree. A screenshot the person hands over is the measurement, and the tool takes one capture per question, never a loop of relaunches, because the second capture is the tuning-by-eye the probe exists to replace.
A tool that performs an irreversible operation performs it without any standing precondition its code does not implement, and it reports success. So before such a tool runs, the standing instructions bearing on that operation are read against what the tool does, its code rather than its help, since a precondition it does not implement is one its help has no reason to mention. A missing step is taken by hand first and declared, then encoded in the tool so the next invocation does not depend on whoever remembers. A tool omitting a step before a deletion closes nothing later, because the operand is gone.
The mandate and the write path arrive in different changes, and only the mandate feels like the work. A mechanism is built, it needs an operand, the operand lives in a file, and nothing in building the mechanism asks how that file gets written. The requirement lands complete and the write it depends on is left to whoever hits it, by hand, with none of the protections a tool write carries. So the question what writes this is asked the moment a surface becomes an operand, and the answer is a tool path before the mandate that needs it.
flowchart TB
gap["A capability gap appears"]
owner{"Does something already own this concern?"}
extend["Extend the owner, so the next consumer inherits it"]
build["Author a tool with a command surface, in the tree"]
loop["A manual loop"]
workaround["A workaround"]
gap --> owner
owner -- yes --> extend
owner -- no --> build
gap -. the tempting moves .-> loop
gap -. the tempting moves .-> workaroundflowchart TB
question["A visual question"]
tool["The look tool · one capture per question"]
shot["The screenshot · what a person sees"]
console["The console log · what the page said while rendering"]
layout["The layout · what the engine computed"]
dom["The markup · what was actually in the tree"]
answer["An answer with a value someone can read"]
question --> tool
tool --> shot
tool --> console
tool --> layout
tool --> dom
shot --> answer
console --> answer
layout --> answer
dom --> answerflowchart TB
invoke["An irreversible operation is about to run"]
preconditions["Enumerate the standing preconditions bearing on it"]
code["Read the tool's code for each · not its help"]
missing{"Does the tool implement every one?"}
hand["Take the missing step by hand first, and declare it"]
encode["Encode the step in the tool for the next invocation"]
run["Run it"]
invoke --> preconditions --> code --> missing
missing -- yes --> run
missing -- no --> hand --> encode --> runF1One home
Every fact has one home. Everything else that repeats the fact is a derivation from that home, or it is drift waiting for a day to happen. The rule applies to a number in a config, a location in a script, a word in a filename and a sentence in a document alike, and each of those has one mechanism that holds it.
F1.1One home per fact
One home per fact. A second copy is a defect, not a convenience. The same fact tends to get stated in several places, and the places stop agreeing.
A limit lives in the tool's config, in a checker, in a script and in three documents. Someone changes one. The others keep enforcing the old value. A copy is cheaper to make than a derivation, and the two agree on the day of copying, so the drift is invisible until it costs something.
Declare a fact once and reach it by reference everywhere else. Pick the home for each fact. Derive every other appearance from it by a generator or a lookup. Delete every copy you cannot derive. Where a fact can be computed from the tree, compute it rather than writing it into a configuration, because a declared derivable fact disagrees with reality the moment reality moves and nothing catches the disagreement.
Change the fact at its home. Every other appearance must follow without a second edit. An appearance that stayed behind was a copy.
A declaration file is the one exempt place, because there the string is the declaration rather than a copy of one. Every consumer reads that file by key, and the exemption never widens to a second file.
Three truths carry most of the tree. One quality config is the only quality config: no per-tool config exists on disk, every tool runs through one runner that builds its config in memory from the one file, and a validator refuses any native config file that appears. Catalogued rules are on by default, so the config carries deviations only. One location declaration holds where every member lives, branches compose so a directory is spelled once, and every script resolves a location by key, so a rename is one edit. One closed vocabulary holds every word a filename may carry, and an undeclared word is an approved edit to the vocabulary rather than a naming choice.
A location is never spelled in a string, and the check for that has four shapes. A declared location spelled as a literal anywhere. The same location assembled from parts, with local constants, arrays and concatenation folded first. A literal tail appended to a lookup when a key already covers the whole path. And any path-shaped string with no anchor at all. Position is not a defence; a path in an array entry or a template is the same defect as a path in a value.
The location declaration has a shape that makes composition free. A branch declares its own place under a root key and every key beneath it resolves relative to that, so a directory is spelled once and a rename is one edit, and a branch that declares a root also resolves as a leaf, so a key keeps working after it gains children. The keys are agnostic and the values are yours: the governance stack reads the application by a fixed key whatever the directory is called, and renaming the directory is editing the value. The lookup is typed over the declared keys, so a key that does not exist fails to compile rather than resolving to nothing at run time. The declaration answers one question only, where a member lives and which governed roots sit inside it. What lives below a root belongs to the naming vocabulary and is never re-listed here.
A location declaration · branches composeapp: root: <application-root> member: <application-member> # → <application-root>/<application-member> builds: <build-output> # → <application-root>/<build-output> testing: root: <test-root> app: <application-suite> # → <test-root>/<application-suite> governance: root: <governance-host> rules: <rule-host> # → <governance-host>/<rule-host> reports: <report-root> # → <governance-host>/<report-root>
The lookup, typed over the declared keystype LocationKey = "app.root" | "app.member" | "app.builds" | "testing.app" | "governance.rules"; export declare function relativePath(key: LocationKey): string; export declare function absolutePath(key: LocationKey): string; const ruleHost = absolutePath("governance.rules"); const suite = relativePath("testing.app");
flowchart TB
subgraph copies["Three homes"]
s1["settings · lines-per-file: 200"]
c1["checker · MAX_LINES = 150"]
r1["readme · files never exceed 150 lines"]
end
subgraph home["One home"]
s2["settings · lines-per-file: 200"]
c2["checker · reads lines-per-file from the settings"]
r2["readme · states the shape, never the number"]
s2 -- derived --> c2
s2 -. no number to copy .-> r2
end
s1 -. drift .- c1
c1 -. drift .- r1flowchart TB
subgraph truths["One truth per concern"]
quality["Quality config · one file, every tool's config built in memory from it"]
paths["Locations · one declaration, every location resolved by key"]
vocabulary["Naming · one closed vocabulary"]
end
tools["Every tool"]
scripts["Every script"]
checks["Every check"]
quality -- in memory --> tools
paths -- by key --> scripts
vocabulary -- parsed --> checks
drift["A per-tool config on disk · a spelled path · an undeclared word"]
drift -. refused .-> truthsG1The filesystem is the architecture
Every extension point is a file in a directory. Adding a capability is adding a file. Removing one is deleting a file. A hand-maintained list of what exists is a registry in disguise, and a registry in disguise is where the next inconsistency lives. The same shape governs pages, rules, checks, templates and documents.
G1.1Adding is adding a file
The filesystem is the architecture. A core file that must learn a name is the wrong design. Every switch over a kind, every record literal of variants and every import list in a composer is a list someone has to remember to update.
A new variant renders through the fallback branch because the switch that dispatches it never learned its name, and nothing reported that. A central file that must learn every new name is edited by every feature, and every edit is a chance to forget one.
Make the directory the registry. Give each variant its own file that registers itself at module scope. Collect the files by pattern, never by name. Delete the switch, the lookup table and the list of imports that had to learn each new name. Where a barrel collects the files, let it be generated from the directory rather than written, and check it in both directions: an entry with no file, and a file with no entry.
Add a variant by adding one file and touching nothing else. If it needed a second edit, the registry is still in disguise.
A shape-discovered surface is the highest-risk artifact in any rename, because a suffix change silently changes what it collects. The collection is compared before and after every move, and a count that dropped to zero is a broken move rather than a clean one.
The registry pattern in disguise has a few recognisable forms: a hand-maintained list, a switch over a discriminator, a record literal keyed by variant, an if on a config flag inside a composer. Each is lifted to a registry plus a per-variant file that self-registers plus a discovered barrel, and a barrel that hand-writes its side-effect imports is the disguise reappearing one level down. The same move governs the rules: a check becomes active by declaring a contract a registry discovers, and adding, removing or reordering governance touches no core file.
A generated index over authored data is regenerated from its source and drift-checked in the gate, never hand-edited. Every scan is depth-agnostic, because a scan anchored to a fixed depth reports pass over what sits one level below it. The index is checked both ways because one direction is decorative: the failure that occurs is a scan resolving a smaller set than it claims and the difference reading as coverage.
The registry has one shape wherever it appears. A variant carries its kind, a relevance test that says whether it applies to a subject, and the operation it performs; the registry carries register, all and get, and nothing else. The kind is a closed union, so a variant of an undeclared kind fails to compile and a lookup for one cannot be written. Each variant file registers itself at module scope, the barrel that imports the files is generated from the directory, and the composer iterates the registry or asks each variant whether it applies, never a name. The same three parts appear whether the variants are pages, checks, codemods or document forms, which is why one primitive serves all of them and a second registry implementation is a duplicate rather than a convenience.
The registry and its variant, typed over a closed kindexport interface Variant<Kind extends string> { readonly kind: Kind; readonly applies: (subject: Subject) => boolean; readonly run: (subject: Subject) => Finding[]; } export interface Registry<Kind extends string> { readonly register: (variant: Variant<Kind>) => void; readonly all: () => readonly Variant<Kind>[]; readonly get: (kind: Kind) => Variant<Kind>; } export const checks = createRegistry<CheckKind>(); checks.register({ kind: "unreachable-export", applies: isModule, run: findUnreachableExports });
flowchart TB
subgraph before["Before · the router learns every name"]
router1["router · switch on the page name"]
router1 --> home1["home page"]
router1 --> terms1["terms page"]
router1 --> faq1["faq page"]
end
subgraph after["After · the directory is the registry"]
pages["pages folder · collected by pattern"]
home2["home page · registers itself"] --> pages
terms2["terms page · registers itself"] --> pages
faq2["faq page · registers itself"] --> pages
pages --> router2["router · never learns a name"]
endH1Fail at the boundary
A failure surfaces at the boundary where it happens. No default, no fallback and no second path carries on as if nothing went wrong. Errors are a language: they use the system's vocabulary, they are machine-processable, and an invalid state is either marked or unrepresentable.
H1.1Loud at the boundary
Errors are language. A failure surfaces where it occurs, and no default masks it. Code that handles every edge case by carrying on hides the one case that should have stopped it.
A missing secret falls back to a placeholder. The deploy succeeds. The service starts talking to nothing, and the first sign is a customer. A fallback turns a loud failure into a quiet wrong answer, and a quiet wrong answer costs more than any crash.
Refuse fallbacks, dual paths and silent defaults. Fail at the first point a precondition does not hold. Say what you expected and what you found. Refuse a default value for anything the configuration should have supplied, and refuse a second path that carries on when the first one cannot. Delete replaced code in the same edit rather than marking it, because a marker is a second path with a label.
Remove one required input and run. The run must stop at the boundary that needed it, naming it. A run that continued has a fallback somewhere.
Fail-fast is for the boundaries of your own system. A surface a person meets still degrades gracefully, with a real page rather than a bare error, and the failure it hides is logged where you will see it.
The debt shapes are named as pairs, never as a list of don'ts, because each pair states what to do instead. Never a shortcut, always a constraint. Never a fallback, always fail-fast. Never a dual path, always a single path. Never a deprecation marker, a tombstone or a compatibility shim, always explicit removal in the same edit. Never a for-now, always now. Never an optional feature the system depends on, always mandatory. Every write is checked for those markers before it lands, and only living code on one forward path survives.
An environment variable never carries a fallback value, and a missing one fails at boot with its name. A guard that fails open is itself a defect, because a guard exists to stop a state and a guard that lets the state through on error has stopped nothing. A boolean flag reads as an explicit positive test rather than a negated default, so an absent flag hides the feature rather than enabling it by accident.
The same missing value, masked and surfacedconst masked = env.HOST ?? "localhost"; const surfaced = env.HOST ?? fail("HOST is not set; the deploy needs a droplet");
flowchart TB
subgraph never["Never · debt"]
shortcut["a shortcut"]
fallback["a fallback"]
dual["a dual path"]
deprecation["a deprecation marker"]
fornow["a for-now"]
optional["an optional feature the system depends on"]
end
subgraph always["Always · leverage"]
constraint["a constraint"]
failfast["fail-fast"]
single["a single path"]
removal["explicit removal"]
now["now"]
mandatory["mandatory"]
end
shortcut --> constraint
fallback --> failfast
dual --> single
deprecation --> removal
fornow --> now
optional --> mandatoryI1Placement is a grammar
Where a file lives and what its name says are one grammar. A container, an optional subject, a concern, then the file. The file's name ends with the concern of its folder. Every word comes from a closed list. A file that fits two roles splits in two and never takes a vague name. The grammar is what turns separation of concerns from advice into a check.
I1.1One legal path per file
Placement is a grammar. A name is a claim about what the code does, verified against the code and never against the old name. Separation of concerns as advice produces a different tree from every person who follows it.
A helper folder appears. Then a utils folder. Then a second helper folder inside a feature. Six months later nobody can say where a new file goes. Without a grammar, placement is a convention carried in reviewers' heads, and every head carries a slightly different one.
Make placement and naming a grammar the check parses, so every file has one legal path. Declare the closed vocabulary and the roots it governs. Parse every path against the grammar in a check. Resolve a collision sideways with a variant, never downward with another folder. Treat an undeclared word as a decision for a person, worked down a ladder: an existing word first, then the is-a test, then the filename is wrong, then the file is wrong. Create every file conformant, because there is no conversion queue.
Pick a file at random and derive its path from its contents alone. If the derived path differs from the real one, one of them is wrong, and the grammar says which.
Files an ecosystem names for you keep their names. The grammar governs what you author, not what your tools require, and a tree carrying another system's ownership markers is never declared a governed root, because its names are identifiers that system resolves at runtime.
Resolution is positional rather than lexical. A word is read by the slot it lands in, so a concern tag is a legal subject: a registry of pools and a pool named base use the same word in two slots with no ambiguity. The one lexical bar is that a subject never equals its concern. A subject folder exists if and only if a container holds two or more sets of one concern that must not merge, because optional grouping would give classification two right answers and make placement uncheckable. Overflow relieves sideways: a collision takes the filename's variant slot and breadth takes a sibling subject folder, and the depth cap is why both slots exist.
Jurisdiction is declared. A key in the configuration is a governed root, and no declaration means no enforcement, so a tree outside jurisdiction keeps its own names. A declaration is a claim verified against the disk: a root declared ahead of the folder governs nothing, fails nothing and reads as coverage. Material authored elsewhere is declared once as an upstream root, and that one declaration exempts it from the naming, tense and reference checks together, because all three fail such a tree and not one of the failures is a defect in it.
Classification is judgement and structure is machine-decidable, and the line between them is where the tooling stops. A check reports that a name does not parse or that a tag disagrees with its folder. It never decides what a file is. That decision is made by reading the file and naming the narrowest accurate tag, verified against the content rather than the old name, and a file with two concerns is a split rather than a compromise tag. The reshape of an existing tree is therefore manual, one container at a time with the gate green between each, because a rename tool rewrites literal text and literal text is what a pattern-resolved surface never uses.
The vocabulary is one typed declaration, and its type is what makes it closed. Every array is declared as a literal tuple, so each slot's legal words become a union type derived from the data rather than written a second time. A root is a key, the containers beneath it are its value, and a flat bucket is a separate declaration because it is declared and never inferred from shape. Each concern entry carries its folder, its file tag and the layer it belongs to, so the plurality rule and the layer spine are data a check reads. The declaration then asserts its own consistency at compile time. A subject that is already a concern tag, a variant that is already a subject, a compound marker that is already a tag, or a concern whose layer is outside the spine fails to compile, so the vocabulary cannot go inconsistent without the whole gate refusing to load. A configuration that carries data and its own consistency proofs, and no reasoning, is the shape every closed vocabulary here takes.
The grammarfolder = <container> | <subject> | <concern> one word, never a dot file = <subject>.<concern>.<ext> | <subject>.<variant>.<concern>.<ext> only when two files would collide depth = container(1) → subject(2, optional) → concern(3) → file a role may be skipped, never repeated, never revisited the file's parent is always the concern folder the file's concern tag equals its parent folder
The vocabulary as a typed declarationexport const LAYERS = ["domain", "application", "processing", "runtime", "infrastructure", "operations", "product"] as const; export const taxonomy = { containers: { "<governed-root>": ["<container>", "<container>"], }, specialContainers: { "<governed-root>": ["<flat-bucket>"], }, concerns: [ { folder: "registries", tag: "registry", layer: "infrastructure" }, { folder: "validators", tag: "validator", layer: "processing" }, { folder: "strings", tag: "strings", layer: "product" }, ], subjects: ["base", "<domain-noun>", "<domain-noun>"], variants: ["<facet>", "<facet>"], grammar: { separator: ".", maxDepthFromRoot: 3, compoundMarkers: ["test", "spec", "generated"], }, } as const; type Config = typeof taxonomy; export type Subject = Config["subjects"][number]; export type Variant = Config["variants"][number]; export type ConcernTag = Config["concerns"][number]["tag"]; export type GovernedRoot = keyof Config["containers"]; type Assert<Name extends string, Overlap> = [Overlap] extends [never] ? true : [Name, Overlap]; export const NO_SUBJECT_CONCERN_OVERLAP: Assert<"subject is already a concern tag", Extract<Subject, ConcernTag>> = true; export const NO_VARIANT_SUBJECT_OVERLAP: Assert<"variant is already a subject", Extract<Variant, Subject>> = true; export const EVERY_LAYER_DECLARED: Assert<"concern layer is not in the spine", Exclude<Config["concerns"][number]["layer"], (typeof LAYERS)[number]>> = true;
flowchart TB
engine["engine · a container, one grouping axis"]
registries["registries · a concern folder, files of one role"]
page["page.registry.ts"]
route["route.registry.ts"]
form["form · a subject folder"]
panel["panel · a subject folder"]
fv["validators"]
pv["validators"]
field["field.validator.ts"]
layout["layout.validator.ts"]
engine --> registries --> page
registries --> route
engine --> form --> fv --> field
engine --> panel --> pv --> layout
form -. two sets of validators must not merge .- panelflowchart LR
word["A word at a slot"]
role{"A role a file plays?"}
thing{"A thing the system has?"}
concern["The concern list · the file's tag"]
subject["The subject list · the first slot of a name"]
rejected["Rejected · a process, an adjective, a grouping label"]
split["The file gets its real role, or splits"]
word --> role
role -- registry, validator, renderer --> concern
role -- no --> thing
thing -- form, panel, route --> subject
thing -- timing, lazy, misc, helper --> rejected --> splitflowchart TB
tree["A tree"]
declared{"Declared as a governed root?"}
governed["Every file inside resolves to one legal path"]
foreign{"Carries another system's ownership markers?"}
upstream{"Authored elsewhere?"}
left["Left alone · a grammar that does not claim a tree enforces nothing in it"]
refused["Refused as a root · its names are identifiers another runtime resolves"]
exempt["Declared once as upstream · exempt from naming, tense and reference checks together"]
tree --> declared
declared -- yes --> foreign
foreign -- yes --> refused
foreign -- no --> governed
declared -- no --> upstream
upstream -- yes --> exempt
upstream -- no --> left