Every AI coding setup hits the same wall. You start with a small project-rules file, it works, so you add to it. Six months later it's 4,000 lines, and the model follows roughly none of it.
The instinct is that the model is ignoring you. It isn't, exactly — you've handed it a document where every line has equal weight and none of it is scoped to the task at hand. Here's the structure that fixed it for me, and the maintenance problem nobody warns you about.
Why the flat file fails
Three separate failure modes, all of which look like "the model ignored my rules":
Dilution. A rule about button padding sits beside a rule about database migrations. Neither is more prominent, so the model can't tell which matters for the current edit. Signal-to-noise collapses as the file grows.
Cost. That file loads on every single session, and re-enters the billed context on every turn. You're paying continuously for payment-webhook rules while editing a marketing page.
Rot. A file that big is impossible to keep accurate, and — this is the important bit — outdated docs are worse than no docs, because they actively mislead rather than merely failing to help.
The structure: halls and rooms
Halls are one file per domain. Rooms are sections inside a hall covering one specific component or flow. The root file is an index and nothing else — it says what exists and when to load it.
CLAUDE.md # index only. loads every session. ~40 lines.1halls/ general-app-design.md # rooms: Buttons, Cards, Sliders, Modals databasing.md # rooms: Users, Licenses, Templates, Audit payments.md # rooms: Checkout, Dunning, Webhooks2 controller-input.md # rooms: MIDI Mapping, Latency, Device Detect- The only file that loads unconditionally. If it grows past ~40 lines it has stopped being an index.
- Loads only when the task touches payments — so you never pay for webhook rules while editing a marketing page.
And the root is genuinely just pointers:
# Project — index
## Halls (load on demand)
- halls/general-app-design.md — any UI component work
- halls/databasing.md — schema, queries, RLS
- halls/payments.md — checkout, subscriptions, webhooks
## Always true
- Tailwind 3.3.3, not 4. Don't assume v4 features.
- Commit to main. Never branch.
Root loads every session. Halls load only when the task touches that domain. Rooms get read on demand inside a hall. The always-on surface stays tiny while the deep rules live exactly where an agent will look when it actually needs them.
What belongs in a room (the part people get wrong)
The most common mistake is filling rooms with things the code already says. That's pure cost with no benefit — the model can read the code.
A room earns its tokens only if it holds knowledge that isn't derivable from the repo:
- Decisions and their reasons. "We use a JSONB blob here instead of columns because the shape varies per tenant." The code shows the blob; only the room explains why, which is what stops someone helpfully normalising it.
- Landmines. "This table has a trigger that rewrites
updated_at— don't set it manually." Invisible until it bites. - Conventions with a preferred default. "Side panels, not modals, for data entry." The code has examples of both because of history; the room says which one is right going forward.
- Cross-system contracts. "This field maps to a Pipedrive custom field ID that isn't in this repo." Genuinely unknowable locally.
What does not belong: file listings, component inventories, restatements of the type definitions, or anything a grep answers faster. If the model can find it in two tool calls, don't pay for it on every turn.
Frontend halls tend to get a room per recurring component — Buttons Room, Cards Room, Sliders Room — each proposing the standard so new components match. Backend halls get a room per data flow — Users Room, Licenses Room — each documenting the gnarly rules about how the app talks to that part of the schema.
Halls and rooms are the storage layer of a bigger setup; how they sit next to compression, watchers and hooks is covered in my whole workflow, warts and all.
The catch: rooms rot, and rot is worse than silence
This is the failure that actually cost me money, so it gets its own section.
A line in one of my context files said "GitHub pushes do NOT auto-deploy." It had been true. It stopped being true when the repo was connected to CI. The agent read it, believed it over the live system, told me a fix wasn't deployed when it had already shipped twice, and then suggested I'd tested it wrong. An hour gone and my temper with it.
The mechanism has a name: documentation bias — favouring a written source while discounting contradictory evidence. And the dangerous zone is exactly where a source is good enough to trust but imperfect enough to fail. A mostly-correct context file is the perfect trap: it's right often enough to earn trust, so the one wrong line gets believed too.
Three rules that keep it survivable:
1. Treat a stale line as a bug, fixed in the same pass. Not noted, not queued. When you catch a room lying, you fix it before moving on. Anything else and the queue becomes the graveyard.
2. Never state environment facts in a doc — state where to check them. This is the big structural fix. Deploy behaviour, current URLs, which account owns what: all volatile. So the room shouldn't say "pushes don't auto-deploy", it should say:
Deploys: GitHub pushes do NOT auto-deploy. Deploy with `netlify deploy --prod`.Deploys: check the live wiring before claiming anything about deploy state. git remote -v # where does this actually push netlify status # is the site CI-linked netlify deploy:list --json | head # did the last push deploy- The removed line was true when written. That is exactly what makes it dangerous — it expires without any signal.
- The replacement can't go stale, because it describes how to look rather than what is true.
A doc that tells the agent how to look stays true forever. A doc that tells it what's true starts decaying the moment you save it.
3. Date the volatile claims. Anything genuinely time-bound gets an explicit date, so a reader can weigh it: "As of 2026-07-09, Sol is ~half Fable's price." A dated claim invites a re-check. An undated one masquerades as permanent.
A hall, written out in full
Abstract advice about structure is easy to nod along to and hard to act on, so here's a real hall. Note how much of it is why rather than what — that's the test of whether a room is earning its tokens.
<!-- halls/databasing.md -->
# Databasing — Hall
> Load when the task touches schema, queries, RLS or migrations.
## Users Room
**Shape:** `users` mirrors `auth.users` 1:1 via trigger. Never insert directly —
insert into auth and let the trigger populate.
**Landmine:** there's a BEFORE UPDATE trigger rewriting `updated_at`. Setting it
manually is silently overwritten. Don't add it to your UPDATE payloads.
**RLS:** every policy checks `auth.uid() = user_id`. There is no service-role
bypass in app code — if a query needs to cross tenants it belongs in an Edge
Function, not the client.
**Verify (don't trust this doc):**
```sql
select tablename, policyname, cmd from pg_policies where tablename = 'users';
```
## Licenses Room
**Shape:** `licenses.seats` is a JSONB array, NOT a join table. Deliberate —
seat shape varies per plan and we were adding columns monthly.
**Consequence:** you cannot join on a seat. Filtering by seat needs
`jsonb_array_elements`. If you need seat-level joins often, that's the signal
to normalise — but that's a migration, not a workaround.
**Cross-system:** `licenses.external_ref` maps to a CRM custom-field ID that
does not exist in this repo. Ask before changing the format.
Every line there is something the code cannot tell you: a deliberate decision and its reason, an invisible trigger, a constraint that follows from a schema choice, and a foreign contract. None of it is a file listing or a restatement of the types.
The rot problem, quantified
Rot isn't uniform — some facts decay far faster than others, and that's what should drive whether you write the fact or a pointer to it.
| Fact type | Half-life | Write it down? | Instead |
|---|---|---|---|
| Deploy wiring, URLs, account ownership | Weeks | Never | Write the command that checks it |
| Model names, prices, API versions | ~1 quarter | Only with a date | "As of YYYY-MM-DD…" |
| File paths, component names | Months | Rarely | A grep pattern beats a path |
| Schema landmines (triggers, JSONB shapes) | Years | Yes | — |
| Why a decision was made | ~Never | Always | — |
The pattern is clean: the more volatile the fact, the more it should be a pointer instead of a statement. Decisions and landmines are near-permanent and belong in prose. Environment state should never appear as a claim at all.
Lint your docs against reality
The rule "treat a stale doc as a bug" only works if something catches the staleness. Mechanical claims can be checked mechanically, so put the check in CI:
#!/usr/bin/env node
// scripts/lint-docs.mjs — fail CI when a doc references something that's gone.
import { readFileSync, existsSync } from 'node:fs';
import { globSync } from 'node:fs';
import { execSync } from 'node:child_process';
const docs = globSync('{CLAUDE.md,halls/**/*.md,docs/**/*.md}');
let problems = 0;
for (const doc of docs) {
const text = readFileSync(doc, 'utf8');
const lines = text.split('\n');
lines.forEach((line, i) => {
const at = `${doc}:${i + 1}`;
// 1. backticked paths that no longer exist
for (const [, p] of line.matchAll(/`([\w./-]+\.(?:ts|tsx|astro|json|md|sh|cjs))`/g)) {
if (!existsSync(p) && !p.includes('*')) {
console.error(`${at} missing path: ${p}`);
problems++;
}
}
// 2. npm scripts that aren't in package.json
for (const [, s] of line.matchAll(/`npm run ([\w:-]+)`/g)) {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
if (!pkg.scripts?.[s]) {
console.error(`${at} missing npm script: ${s}`);
problems++;
}
}
// 3. undated volatile claims — prices, models, deploy behaviour
if (/\$\d+\s*\/\s*\$\d+|auto-deploy|does not deploy|per 1M/i.test(line)
&& !/\b20\d{2}-\d{2}-\d{2}\b/.test(line)) {
console.error(`${at} volatile claim with no date: ${line.trim().slice(0, 80)}`);
problems++;
}
});
}
// 4. the specific lie that cost me an hour: claims about deploy wiring
const remote = execSync('git remote -v').toString();
for (const doc of docs) {
const t = readFileSync(doc, 'utf8');
if (/pushes? (do ?not|don't) auto-?deploy/i.test(t) && remote.includes('github.com')) {
console.error(`${doc} claims pushes don't auto-deploy — verify against the deploy log`);
problems++;
}
}
console.log(problems ? `\n${problems} doc problem(s)` : 'docs clean');
process.exit(problems ? 1 : 0);
Check 3 is the one that pays for the script. It doesn't try to know whether a price is correct — it just refuses to let a volatile claim exist without a date, which converts "silently wrong" into "visibly old". That's the whole game with documentation rot: you can't stop facts changing, you can only stop them pretending to be current.
The token budget, measured
Splitting into halls is only worth the effort if it actually shrinks the always-on context. It does, substantially:
FLAT FILE (before)
CLAUDE.md .......................... 4,100 lines ~52,000 tokens
loaded every session, every turn
HALLS + ROOMS (after)
CLAUDE.md (index only) ................ 40 lines ~500 tokens ← every session
halls/general-app-design.md .......... 320 lines ~4,100 tokens ← on demand
halls/databasing.md .................. 280 lines ~3,600 tokens ← on demand
halls/payments.md .................... 210 lines ~2,700 tokens ← on demand
halls/controller-input.md ............ 160 lines ~2,000 tokens ← on demand
typical session (index + 1 hall) ................. ~4,600 tokens
worst case (index + all halls) .................. ~12,900 tokens
About a 90% cut on a typical session, and even loading everything is a quarter of the flat file. But the token saving is the second-order benefit. The real win is that the 40-line index actually gets followed, where 4,100 lines of undifferentiated rules did not — because at that size the model can't tell which of them apply to the edit in front of it.
The money pattern: specific beats general, always
The last piece is layering. A global file holds what's true everywhere — git policy, tone, deploy accounts. A project file holds what's true only here. The agent reads the narrow one last, so specific always wins.
This matters more than it sounds. My global stack default is Tailwind 4. One site predates it and runs 3.3.3, where v4 syntax silently breaks. That single project-level override has prevented more damage than any other line I've written, because without it the general rule is confidently wrong in one specific place — the worst kind of wrong.
The verdict
Split by domain, index at the root, load on demand, and only write down what the code can't tell you. Then accept that the whole structure is only as good as your discipline about pruning it, because the tree's failure mode isn't getting too big — it's quietly going out of date while still sounding authoritative.
Write pointers, not facts. Date anything volatile. Fix a lying room the moment you catch it.
A stale doc is a bug with better formatting.
Want your project's AI context architecture designed properly? Get in touch, or browse the work first.