Guidelines, design patterns, and hard-won lessons worth adopting on most new software projects — established practice, hardened by real production fixes. Hand this to a new project so it starts smarter. Each entry: the "why", concrete do/avoid, external references, and a box holding what the principle looked like in this repository. The principles are portable; the boxes are not, and every one names a file you can open. Where a principle has no instance here, the box says so.
Most projects die of complexity they added before they needed it. A well-structured modular monolith ships faster, is easier to reason about, and can be split later along seams you've actually discovered. Distribute (microservices, queues, caches, sharding) only when a real constraint forces it.
Do
Avoid
Refs: Fowler — MonolithFirst · Fowler — YAGNI · Shopify — Modular Monolith
ADR-08 planned a lexical BM25 index with embeddings only where paraphrase mattered. When this box was written none of it was built — no index, no ranking, no embedding anywhere in app/, and search_claims was a substring scan over rows the tenant chokepoint had already returned. Half of it was built on 2026-08-04 and this box said otherwise until 2026-08-05, which is the same defect it is describing, one level up: app/state/search.py is now FTS5 with bm25 over the passage store (ADR-71), and the box beside it at §27 said so while this one still said nothing existed. The lexical half is built and reaches the record only through the assistant; the embedding half, the one that would match a docket change to an obligation in the company's own words, is still unbuilt. What has not changed is the reason the gap was survivable: the deterministic diff enumerates the changes, and retrieval only ever ranked context. The cost was in the drawing, where the architecture document — since absorbed into docs/tdd.html — showed "passage store — lexical BM25" as a solid box beside components that really existed, for weeks.
Coupling is what makes software hard to change. Keep the core domain independent of frameworks, databases, and I/O; let dependencies point inward (ports & adapters / hexagonal / clean architecture). You can then swap a UI, DB, or vendor without touching business logic, and test the core in isolation.
Do
source tag marks origin). Adding the Nth provider becomes a pure adapter change; dashboards, analytics, and AI context keep working unchanged and vendor quirks don't ripple inward.Avoid
Seen in peers: vitalnexa normalizes every wearable (Oura/WHOOP/Dexcom) into one test_results shape; citelocal wraps every data provider behind one interface — a new provider is one adapter fn.
Refs: Cockburn — Hexagonal · Martin — Clean Architecture · Fowler — PDD layering
The correctness core — ingestion, normalization, citation verification, the diff — touches no network and no vendor SDK. app/chat/agent.py and app/interpretation/propose.py both put the model behind an injected transport and import anthropic inside a factory, only when a key exists, and tests/test_chat_agent.py asserts that importing the agent loads no SDK. That is what lets make test run on a clean checkout with no key, and lets a reviewer audit the part whose failure would make the product pointless without trusting anything about the model.
Cross-cutting logic — auth, redaction, rate-limiting, formatting, per-item controls — duplicated across call sites drifts out of sync and re-introduces bugs you already fixed. One place to change is one place to get right and to test.
Do
Avoid
Refs: SRP · Fowler — Patterns of Enterprise App Architecture
app/diff/engine.py::passage_refs read the passages table with no company scope, so knowing a version id was enough to read another tenant's source text — and version ids are short and guessable, which made that reachable rather than theoretical. 56 passing tests missed it because no test asked the question; adversarial review found it. The read now joins Passage to DocumentVersion and goes through app/state/queries.py, where every tenant scope lives (ADR-29), with company_id keyword-only and no default so it cannot be omitted or passed into the wrong slot.
The schema outlives most code and is the hardest thing to change under load. Model it on purpose; evolve it with versioned, ordered migrations that are safe to run on live data.
Do
source field with precedence (human-authoritative > external feed) so a re-sync can't clobber a human edit.Avoid
docker-entrypoint-initdb.d, which runs only on an empty volume), or two schema paths (auto-create_all + migrations) that silently drift. Make a migration runner with an applied-ledger the sole authority.Refs: Kleppmann — DDIA · Fowler — Evolutionary Database Design
deploy/entrypoint.sh ran no schema work at all when the database file already existed, so the first deploy after a column was added would have answered every proceeding, claim and verification screen with no such column: document_versions.source_url. It was reproduced exactly before it was fixed, and no test could have caught it: tests build their schema from the current models every time, so they never meet yesterday's database. app/state/migrate.py derives what to add by asking SQLAlchemy what the models declare and the database what it has — not the five ALTER TABLE lines that would have fixed the instance and left the next column to break the next deploy.
Syncs, imports, and retries happen. A write keyed by a natural key that UPSERTs converges on replay instead of duplicating. And a secondary effect (analytics, enrichment, memory) must never roll back the primary transaction.
Do
last_sync = NOW()) in the same transaction that selects it, before launching parallel fetches — gives at-most-once behavior across concurrent triggers (tabs, devices) without standing up a queue.Avoid
Refs: Stripe — Idempotency · Kleppmann — DDIA
Hardened here: lab ingestion behind a savepoint on upload; upserts keyed by (principal, biomarker, date).
app/seed.py runs on every make run and writes nothing the second time: versions and changes are reused by derived id, and every claim and escalation is keyed by a stable id and skipped when present. An escalation is never rewritten once created, because a person may have resolved it and the resolution is the part worth keeping. deploy/entrypoint.sh seeds only when no database file exists, so a redeploy keeps the audit chain instead of laying fresh demo rows over it.
Every interface between components (HTTP API, event, library) is a promise. Make it explicit and evolve it without breaking callers, so teams and clients move independently.
Do
Avoid
Refs: Stripe — API versioning · SemVer
Adding actor attribution to the audit digest changed the hashed field set, so every row states its own scheme in digest_version and verify_chain dispatches on the row's own value (ADR-17). Re-hashing the existing rows under the new scheme was rejected: a chain rewritten by the process that verifies it proves nothing, and the old hashes are the evidence. The cost, accepted and written down, is that verification carries every scheme forever — app/state/audit.py holds three, and an unknown one raises rather than falling back to a known one.
A shallow "success" is not validity — HTTP 200 doesn't mean a page is what you think (expired postings, generic landing pages, JS shells all return 200). Trusting the shallow signal surfaces stale/wrong data to users.
Do
Avoid
Learned here: expired job postings that all returned 200; fixed with content-marker + expiry + entity-on-page checks.
The 102 real filings under data/real/ each carry a source URL, a docket, a filing date, a retrieval timestamp and a SHA-256, and every haul was re-checked by a separate skeptic that re-fetched the source and recomputed the hash. The skeptic read the text looking for the absence of PDF artefacts, because clean well-paragraphed prose is the signature of something generated rather than extracted; the corpus is genuinely dirty, with 99 of 102 carrying a docket caption and 35 a word hyphenated across a line break. An empty haul was defined as a successful run and a fabricated one as the end of the project (ADR-58).
Networks, dependencies, and disks fail; the question is whether your system fails well. Bound every remote call, retry transient errors safely, and degrade gracefully instead of cascading.
Do
Avoid
Refs: AWS — Timeouts, retries, backoff w/ jitter · Nygard — Release It!
app/chat/agent.py names nine degraded states — no key, no toolset, transport failed, unreadable response, model declined, step cap, tool failed, tool halted, uncounted withholding — and each carries the sentence the reader sees, so a failing model produces an announcement rather than a 500. app/notify/transport.py returns three answers rather than two: sent, not-configured, and failed. An admin who provisions a login and is told nothing assumes the person got the email, waits, and the product looks like it lost somebody.
Anything that can exceed a request budget (proxies typically cut at ~60s) must not run synchronously in the request. Serve the last stored result instantly and refresh asynchronously.
Do
parallel_map-style primitive that returns results in input order and captures a per-item failure in its slot (so one unit's error never sinks the batch). Give each worker its own DB session — sessions aren't thread-safe — and keep a kill switch (width 1 → sequential) to revert by config.Avoid
Refs: 12-Factor (VIII, IX) · Google SRE Book
Learned here: a ~2.5-min search timed out synchronously; a raw background thread died under a cycling deploy — moved to stored-result + managed refresh.
No instance in this build: nothing has yet needed to leave the request. The heaviest real run — 144 changes across a filing pair of 1,024,409 and 1,024,536 characters — takes 0.78 seconds. app/web/views/integrations.py says in its own docstring that there is no crawler, no poller and no scheduler, and that ScheduledRun is a table of cadences with no fetcher behind it, rather than letting a schedule screen imply one.
Deploys that skip the safety net or run migrations by hand break in production; rapid successive deploys recycle instances and kill in-flight work; assuming a deploy "went out" is how stale code lingers.
Do
/health) before you try to optimize it — and verify recovery by probing the service, not the deploy tool's status, which can lag real recovery by minutes. Design the client to degrade gracefully during the window (timeouts, honest retry) rather than chasing true zero-downtime first.Avoid
Refs: 12-Factor (V) · Fowler — Blue-Green
Deploying the application behind the marketing site found three defects, none of which a test could have. app/state/db.py reads VERBATIM_DATABASE_URL; every run that day set VERBATIM_DB_PATH, which nothing reads, so SQLite had been quietly opening ./verbatim.db relative to the working directory and working by accident — in a container that directory is /srv, owned by root, and the app would not have started. HOME_URL was /, which after deployment would have signed a person in and shown them the sales page.
You can't fix what you can't see, and guessing wastes time and "fixes" the wrong thing. Emit signals, keep an audit trail, and diagnose by reading real state before theorizing.
Do
Avoid
Refs: Google SRE Book · OpenTelemetry primer
Learned here: "why reconnect Oura?" — answered from DB + audit log (no connection row, no connect event), not assumption.
The live site had been scrolling sideways at 390px all day with the suite green — security.html at 414 and subprocessors.html at 477 — and it was found by measuring the deployed pages with getComputedStyle rather than by looking at them. The cause was not the tables anybody would have guessed: .stage b { white-space: nowrap }, written for a short status label, is a descendant selector, so it also caught the <b> opening each row's sentence and held a whole paragraph on one line.
A frozen set of cases run on every change tells you whether the system improved or quietly broke; most outages are re-breaks of something once fixed. For non-deterministic (AI) systems, split the net: deterministic checks in CI, judged checks on demand.
Do
Avoid
Refs: Fowler — Test Pyramid · Fowler — CI · OpenAI Evals · Promptfoo
tests/test_app_wiring.py asserted five hand-written paths and passed while projects.py and review_centre.py — 2,000 lines and nine routes — were mounted nowhere and base.html linked to /projects from every page. Every nav on every screen pointed at a 404, which is the first thing a reviewer clicks. Rewritten to derive its answer from app.routes and pkgutil over app.web.views (ADR-23), it then caught three more unmounted routers later the same day — integrations, users_admin and invite_accept. A list a person maintains cannot catch a module that person forgot.
Patching the one reported line leaves the same failure lurking on paths you didn't look at and invites a re-break. Fixing the class + a guard is what makes a system get monotonically better.
Do
Avoid
Learned here: an email listing's description orphaning — the cause was two independently-keyed lines, not the one row reported.
Ten tables across six templates could push a page sideways on a phone. The fix is one rule in the stylesheet making the table its own scrolling box (ADR-47), not ten wrappers in six files, so a table added tomorrow is covered without anybody remembering. The same move fixed a false 100% in the eval scorecard: app/evals/report.py now derives the count of independent samples by de-duplicating on the identity that makes two probes the same evidence, rather than trusting the number a caller passes.
Code is read far more than written; clarity is a feature. Small, reviewed changes are easier to reason about, test, and revert, and reviews spread knowledge and catch what tests can't.
Do
Avoid
Refs: Google — Code Review · Fowler — naming/cache-invalidation
Seven of the eleven defects logged in docs/.ai/findings.html were found by adversarial review rather than by a failing test. Three of the eleven were not bugs in code at all but false statements in documents, written in good faith by people describing what they intended to build — which is exactly how a model produces a confident wrong answer, and why each correction stays visible rather than being deleted.
Intuition about performance is usually wrong; optimize the measured hot path, not a guess. The common real wins are algorithmic and I/O-shaped, not micro-tweaks.
Do
Avoid
Refs: Knuth — premature optimization · Use The Index, Luke
_spans_of in app/verification/verifier.py carried a docstring claiming O(n) rather than O(n·m). Measurement disagreed: 1.29s on a 40,000-character document against a 200-character repeated quote, and 235ms on a 144KB filing against a ten-underscore quote that hit 102,000 times, because a per-hit re-normalization costs O(m) while hits scale with n. The re-check was correct and load-bearing, so it was replaced rather than removed — two integer comparisons against arrays the projection already carries.
Authorization scattered across handlers drifts and leaks. A single resolution chokepoint, least privilege, and context-aware visibility keep the confidential surface small and auditable. Secrets and personal data must never live in code or logs.
Do
Avoid
Refs: OWASP Top Ten · OWASP ASVS · 12-Factor — Config
/s/<token> was missing from the guard's public list, so an anonymous open answered 303 /login?next=%2Fs%2F<token> — putting a live bearer token into a query string, where it reaches the access log, the Referer header and browser history. A redirect that carries the thing it was protecting is worse than no guard, because it looks like one working. What is still open is disclosed rather than implied away: an admin holds user.manage, so an admin can grant themselves obligation_owner and approve their own work — the chain records the grant, and nothing prevents it.
Every dependency is code you didn't write but now own — a surface for bugs, breakage, and supply-chain attacks. Prefer borrowing a pattern over adding a library; when you do add one, pin and vet it.
Do
Avoid
Refs: OWASP Dependency-Check · SLSA (supply-chain)
Applied here: kept the existing test harness rather than adding an eval framework — "borrow patterns, not dependencies."
Passwords go through hashlib.scrypt from the standard library (ADR-15) — no new dependency for the one thing a reviewer checks first, with the cost parameters written onto each user row and read back at every verify, so raising them later is one edit and every stored password keeps working. app/config.py reads .env in fifteen lines of standard library rather than adding python-dotenv, which keeps a reviewer's make run on one clean path (ADR-14).
Data you don't hold can't leak or be misused. Minimize collection, define a lifecycle, and make deletion real — both good practice and, increasingly, the law.
Do
Avoid
Refs: GDPR Art. 5 — minimization · Privacy by Design
app/state/retention.py names every table in the data model with a window and a reason, and tests/test_retention.py refuses a table that has no rule — so a new table cannot ship without somebody deciding how long it keeps its rows. The hard part is written out rather than implied: the audit chain is append-only, so a subject row is purged while the audit row naming it survives, pointing at an id that no longer resolves. privacy.html states what can be erased and what cannot, instead of promising a deletion the chain would have to break.
The worst AI failure is confident and wrong — it happens when the model reads stale/partial state and insists on it. The fix is architectural: get inputs into the system of record, ground the model on that latest state, and have it reconcile discrepancies rather than defend what it can't verify.
Do
Avoid
Refs: Anthropic — Effective agents · Prompt engineering · RAG
Learned here: a reply that defended a stale lab value over the user's uploaded one — fixed by ingesting the upload and guarding with a grounding judge.
WithheldClaim in app/state/claims.py has no statement field, and slots=True means one cannot be attached at runtime either — a template cannot render what the object does not have, so no stylesheet change and no helpful refactor can leak an assertion that failed its citation. The test asserts the fabricated statement string is absent from the response body entirely, not greyed out, because a greyed-out assertion is still an assertion and absence is the only treatment a reader's eye cannot complete.
Model calls cost real money and add real latency; unbudgeted, they surprise you in the bill and the p99. Treat tokens and time as first-class budgets.
Do
cache_control and put ALL per-request/volatile context in a separate later block — one stray timestamp in the cached prefix zeroes your hit rate.Avoid
Refs: Anthropic — Prompt caching · Message Batches
app/chat/agent.py pins MODEL_ID in code, caps MAX_OUTPUT_TOKENS at 8000 and MAX_TOOL_STEPS at 4, and sets thinking effort to medium rather than the default, with the reason in the comment beside it: the expensive judgements — does the citation verify, may this person approve — are made in Python before the model sees a claim. persona.screen() runs first and is a regular expression, so an unmistakable jailbreak or cross-tenant request is refused before a token is spent, and the refusal is auditable as a deterministic decision rather than as the model's mood on the day. When the step cap is reached the reply says the answer is incomplete rather than presenting the partial one as final.
Everything you put in front of a model is paid for on every call, and noise crowds out signal. The failure is rarely a missing fact — it is a wall of state the model must wade through. Treat the context window like a budget with line items you can name.
Do
Avoid
Refs: Anthropic — Effective context engineering
Learned here: a per-turn context of ~2,400 tokens was 57% one block — and most of that was a raw settings dump carrying ~3,000 chars of an unrelated agent's cached output.
Every tool result in app/chat/tools.py is capped — 25 rows, a 600-character excerpt, a 4,000-character note — and what the cap left out comes back as omitted, in a different key from withheld. Folding them into one number would let "we trimmed the list" read as "a citation failed", and the count this product exists to report would stop meaning anything. Both keys are present on every result including refusals, so a caller summing them can never reach a missing key and read the absence as zero.
Prompts are load-bearing code with no compiler. As models improve, older prompts accumulate scaffolding they no longer need — but some of that verbosity is a learned correction that encodes a bug you already paid for. You cannot tell the two apart by reading. Measure.
Do
Avoid
Learned here: a 42% prompt cut looked clean at 8/8 vs 8/8; repeat trials then caught it reintroducing a bug fixed months earlier, and the harness's own verdict logic was wrong in the same direction.
No instance in this build: no prompt here has been A/B'd, and app/evals/ scores the deterministic spine and calls no model. What exists is a pin rather than an experiment — tests/test_chat_agent.py asserts the system prompt is the persona's and nothing else — so the harness this principle asks for is the missing half.
Good defaults prevent whole categories of mistakes; reversibility turns a scary action into a safe one; and a UI should degrade honestly and work for everyone.
Do
Avoid
Refs: Nielsen — Heuristics · WCAG
The glass header failed contrast at the opacity that looked best in a still: nav links measured 3.50:1 in light and 2.63:1 in dark once a dark film scrolled under the bar, and were raised until they measure 4.67:1 and 5.03:1, sampled every 60px down the page. The landing video has no autoplay, its poster frame is the withheld claim and its full transcript sits in the figcaption, so a reader who cannot or will not play it has still seen the argument — reduced motion is respected by construction rather than by a script that switches autoplay off.
Config in code can't vary per environment and leaks secrets; risky changes shipped all-at-once are hard to unwind. Externalize config and decouple deploy from release.
Do
Avoid
Refs: 12-Factor — Config · Fowler — Feature Toggles · Trunk-Based Development
.env.example listed five variables while the code reads sixteen, and VERBATIM_DATABASE_URL — the one that decides where the database is — was absent entirely. It is a SQLAlchemy URL, not a path, and setting a path does not fail: SQLAlchemy quietly opens ./verbatim.db relative to the working directory. That cost an hour, and every name in the file is now derived from the code with grep rather than remembered, because an example listing variables nothing reads sends the next person hunting for the effect of setting one.
Decisions and durable knowledge that aren't derivable from the code get lost. A small, curated set of living docs pays for itself every time someone (or an agent) needs the "why".
Do
.ai/) and a README that gets someone running fast.Avoid
Refs: ADRs · Fowler — ADR
An audit of docs/.ai/decisions.html against the code found five ADRs that had stopped being true: ADR-10 claimed Verbatim had its own droplet when no token for one exists anywhere, ADR-11 chose a Caddy that was never installed over the Traefik that actually serves the site, and ADR-13 said four screens where there are nine templates and eleven routes. Each keeps its original text with the correction beneath it, because the reasoning history is the point and a stale ADR is worse than a missing one — it reads as a decision that held. docs/.ai/state.json is generated by scripts/status.py so that one file in the repository cannot go stale, and where prose disagrees with it, it wins (ADR-42).
A good fallback keeps the system up. A silent one keeps the system up while quietly changing what it means — and because nothing throws, nobody looks. The dangerous shape is a fallback that returns a value of the right type and shape but not the right quality: a vector of the right width carrying no meaning, a cached answer standing in for a live one, a stub that returns success. These survive for months because every check downstream passes.
Do
CHANGE_ME, TODO, your-key-here are truthy strings; a bare if key: reads them as configured. Normalize them to empty at the edge.Avoid
Learned here: a stubbed HTTP client plus VOYAGE_API_KEY=CHANGE_ME made the platform report the voyage provider for months while every vector was a hash — and 55% of the ranking score was cosine over those hashes.
Every model path in the product announced itself as off while a valid key sat in .env, because nothing loaded that file into os.environ. Every fallback fired, every message was honest and the system was still wrong: nothing looks broken when each component is politely reporting a limitation it does not have. app/config.py now loads it before anything reads it, and the real environment always wins over the file. The same commit found the seam underneath — the chat view resolved app.chat.engine.answer while the engine shipped as app.chat.agent.run_turn, so a legitimate "not wired in" reply covered a name two agents spelled two ways.
Embeddings, hashes, encodings, tokenized indexes — derived data is only comparable within the scheme that produced it. Half-migrated, the system does not fail: it answers every query, plausibly, and wrongly. That is strictly worse than not migrating, because there is no error to notice and the old behaviour is gone.
Do
Avoid
Learned here: the backfill covered 196 of 636 vectors and skipped every column the recruiter ranks against — running it as written would have made ranking worse than the all-hash state it was fixing.
There was no index here to migrate when this box was written, and there is one now: app/state/search.py builds an FTS5 index over the passage store. It is rebuilt whole rather than incrementally, and FTS5 triggers were rejected for exactly this section's reason — a trigger makes an index look self-maintaining, and the day the tokenisation scheme changes it writes new-scheme rows beside old-scheme ones, which is the half-migrated state this principle forbids. The stored derived data is the audit digest, and it deliberately does not migrate: app/state/audit.py freezes each scheme, writes the current one on every new row and dispatches per row, because a chain rehashed by the process that verifies it proves nothing. That is the escape this principle does not name — where the derived value is the evidence, bind the scheme to the row and pay for the extra branch forever. app/state/migrate.py takes the same line on columns: it never backfills, and a new column is NULL on old rows because NULL says the schema of the day did not record this.
Verification answers "does this match the source" at one moment, against one version. It does not answer "is the source still current" — a different question that nothing in the check touches. The failure is quiet by construction: the fact was genuinely verified, the evidence is real, the provenance is clean, and the answer is now wrong. Nothing throws, because nothing changed on our side. Something changed on theirs.
Do
Avoid
Learned here, while finding real people to interview. A certificate of service in IURC Cause No. 45591 (July 2021) gave AES Indiana's counsel as tnyhart@btlaw.com — filing-verified, sworn, correct at the time. The 2026 filing in Cause No. 46394 gives tnyhart@taftlaw.com. Same person, same role, different firm; the firm changed and no document announced it. Both addresses were "verified against a public filing" and only one works. Had we written the first into an outreach log and moved on, the email would have bounced with a provenance note beside it saying the address was confirmed.
Load-bearing for this product, not just for this repo: it is the argument for scoping every citation to a proceeding version (ADR-004, ADR-005) rather than to a proceeding. An analyst reading a claim cited to the draft, months after the final order landed, gets a correctly-cited answer to a question they are no longer asking.
verify_citation_for_version never consulted DocumentVersion.source_sha256, so its verdict was bound to a version id rather than to that version's bytes: an out-of-band edit far from the cited offsets left the citation verifying clean while the stored hash visibly disagreed with the stored text. It refuses on mismatch now, with its own reason, so a tampered version is never confused with a quote that simply does not match. Four outreach addresses have since bounced — all four read off certificates of service, all four filing-verified.
Sections 1–28 came from a peer project. The three below were earned here, in one 48-hour build, and each one describes a failure the twenty-eight above do not reach. They are listed last because they are the newest, not because they matter least: the first of them cost more time than any other defect in this repository.
A test proves a component can work. Almost nothing proves it is reached. So a module can be complete, correct, covered and unreachable at the same time, and the suite stays green because every test imports the thing directly and never asks who else does. This is not a testing gap that better tests inside the module would close — the code under test is fine. What is missing is an assertion about the edges between modules, and edges belong to nobody, which is exactly why nobody writes them.
Do
Avoid
Born here, five times in one day. Two routers holding about two thousand lines were mounted nowhere while the masthead linked to one of them. The assistant was written, tested and never included in base.html. Its engine shipped as app.chat.agent.run_turn while the view resolved app.chat.engine.answer. Nothing loaded .env, so a valid key never reached the process. The approval route was empty because the agent seeding it died partway. No test caught any of the five.
tests/test_app_wiring.py is the fix, and the fix is the derivation rather than the assertion: it reads app.routes, walks the views package, and fails naming the router and its orphaned paths. Written as five hand-written paths it had passed through all of the above. Rewritten, it caught integrations, users_admin and invite_accept across two commits, and then caught permissions — six paths, a 1,556-line view and a 670-line template, complete and unreachable — before that code was ever committed. That is the whole argument for the section: the same defect, found in seconds, by a test nobody had to remember to update.
Every claim in a document is true when written and none of them are re-checked. Code has a compiler, a type checker and a suite; a sentence has a reader who may never come. So the page that describes the system keeps describing the system as it was, and the more carefully the sentence was sourced when written, the more convincing it stays after it stops being true. Section 25 covers letting a decision record rot. This is the wider case, and it is worse, because the reader who finds one false sentence is right to stop believing the others.
Do
Avoid
Born here, repeatedly. The live site told every reader "every document in the demonstration is invented" for hours after 102 real filings landed and a real Kentucky version pair was loaded into the workspace. The README listed three things the product did not do; all three had been built. A roadmap page still called a real corpus a relationship to secure rather than code to write, months of work away, while the corpus sat in data/real/. Every one of those sentences was true when written, and every one was found by the owner reading the page rather than by anything automatic.
There is still no general guard, and pretending otherwise here would be the same defect. What exists is narrower and worth naming: tests/test_responsive.py asserts the rendered pages, tests/test_app_wiring.py asserts every link the masthead renders resolves, and the README's line counts now carry the command that produced them. The rest is a habit, and a habit is not a guard.
A passing test reports on what it looked at. It says nothing about what it did not look at, and it reports the same green either way. So a guard pointed at half the system is indistinguishable from a guard pointed at all of it — and it is more dangerous than no guard, because its name promises the coverage its glob does not deliver. Section 12 says every fix ships a regression guard. It does not say the guard's reach is a thing that can be wrong.
Do
Avoid
Born here. tests/test_responsive.py only ever read app/web/. The marketing site under deploy/site/ scrolled sideways on a phone all day with the suite green — two live pages pushed past the viewport at 390px by a white-space: nowrap reaching prose through a descendant selector. The rule was already forbidden. The guard was simply looking somewhere else.
The repaired test reads both trees, and getting there took two false starts worth recording: the first replacement passed because it matched a comment that quoted the offending rule, and the second because the rule it found sat inside a media query that never fired at the width being tested. Both were green, both proved nothing. The rule that broke the two live pages now has a test named for it, and the test fails when the fix is reverted — which is the only evidence that a guard guards anything.