--- title: Reconciling drifted docs — tier by tense, and record reversals in a ledger date: 2026-07-25 category: conventions module: documentation problem_type: convention component: documentation severity: high applies_when: - Auditing documentation against the code after a period of undocumented change - Deciding whether a stale document should be corrected, annotated, or deleted - A plan or milestone describes a feature that was later dropped or replaced - Writing or reviewing an index that describes what another document is for - Asked whether the docs would pass a review, or to make them pass one - A decision is being reversed and the old rationale needs to survive the reversal tags: [documentation-conventions, staleness, tiering, drift, supersession, decision-log, audit, verification, indexes] --- # Reconciling drifted docs — tier by tense, and record reversals in a ledger ## Context Five weeks after the last documentation pass, this repo's docs described a site that partly no longer existed. `/map`, `/stats`, `/tracker`, Leaflet, a light theme, and `hero_image` on entries had all been deliberately removed — but several documents still presented them in confident present tense, and nothing marked those documents as historical. The trigger question was *"straighten this out so a repeat review returns ok."* The answer depended on a distinction the tree did not encode. [`claude-md-content-tiering.md`](claude-md-content-tiering.md) established that **descriptions drift and rules do not**, and tiered content by *type* (rules stay in `CLAUDE.md`, descriptions move to `docs/`). This pass confirmed that thesis again — every one of 20 verified defects was a description of code, config, or a command; not one was a rule that had gone wrong on its own. But content-type tiering alone did not answer what to *do* with 41 completed plans and 4 milestone specs, because those are neither rules nor current descriptions. The missing axis was **tense**. ## Guidance ### 1. Tier by tense, then treat the halves oppositely | Kind | Files here | Claims | Staleness is | Treatment | |---|---|---|---|---| | **Present-tense** | `CLAUDE.md`, `docs/reference/`, `docs/guides/`, `README.md`, `CONCEPTS.md` | "this is how it *is*" | a **defect** | correct against the code | | **Past-tense** | `docs/working/plans/`, `specs/`, `milestones/`, `summary.md`, `pm-analysis.md` | "this is what we decided *then*" | **correct and expected** | annotate only, never rewrite | A completed plan *should* be stale — that is what makes it a record. Rewriting 41 plans to match today's code would destroy the audit trail of *why* each thing changed, and the work is unbounded. The defect was never their staleness; it was that nothing told a reader they were history. `docs/solutions/` straddles the split deliberately: past-tense incident, present-tense guidance. That is why its `applies_when` frontmatter matters more than its narrative — the frontmatter is the part that must stay true. ### 2. Ledger plus inline notes — neither alone is enough Two mechanisms, because each covers the other's failure: - **A supersession ledger** (`docs/reference/superseded-decisions.md`) — one table: what was planned, where it was planned, what is true now, when it changed, why. This is the only thing that answers *"what did I change my mind about?"* in one place, which is the question a review actually asks. Alone, it has an indirection problem: a pointer you might not follow. - **Inline `> **Superseded …**` notes** at each stale claim, so the claim cannot be read un-corrected. Alone, it has a completeness problem: no changelog view, and coverage is only as good as the annotation pass was. **Prefer the annotation patterns the repo already uses.** Here, `architecture.md` already carried `> History:` notes and `trip-switching.md` already carried `> **Changed 2026-07:**`. Inventing a third convention would have been worse than adopting either. **Add the ledger row in the same commit as the reversal.** A ledger that lags is worse than no ledger, because it is trusted. This is the same failure mode as a lagging plan `Status:` line — and one was found during this pass: a plan read `📋 Not started` while its feature was merged. ### 3. Also record what was *not* reversed A ledger of only reversals makes every old document look suspect. This one ends with a short "decisions that were *not* reversed" section — the `pm-analysis.md` SKIP list still stands, the weather integration shipped exactly as specified, the stats computation survived and only *moved*. Without it, a future reader re-litigates settled decisions because the surrounding docs looked old. ### 4. Separate "the docs are wrong" from "the code is wrong" An audit against code finds both. Mixing them makes the diff unreviewable and stalls the documentation fix behind a behaviour decision. Route code-side findings to a separate recommendations document and **explicitly do not act on them**. Here that kept a 300-line docs diff clean while still capturing that `make start` is broken on any clean checkout. Documenting a trap is not the same as fixing it — and is the right move when the fix is someone else's call. Per the tiering doc, a gotcha has no natural trigger that opens a file, so a live trap belongs in `CLAUDE.md` even while its fix stays unscheduled. ### 5. Verify against the artifact that decides behaviour, not the prose about it Every finding must come from the thing that actually determines behaviour: | To check | Read | |---|---| | What a command does | the `Makefile` — including macro-generated targets, which a grep for literal target names will miss | | What a build produces | the build script (`package.json`), not a prose asset table | | Whether a file is a source or an output | which file *imports* it, and how it reaches the page | | Whether a feature exists | the absence of its mechanism, not the absence of a mention | | Whether a plan shipped | the branch history, not the plan's own `Status:` line | This is also where an audit catches *itself*. One draft finding here claimed the asset table was missing four source files; reading `package.json` showed the table lists esbuild **entry points**, so imported-only sources were correctly absent. The finding was withdrawn. **An audit that never withdraws a finding has not been checking itself.** ### 6. Audit against the state that is actually current The outer repo pinned a `user/` commit that lagged the submodule's real HEAD, and that pin was the default checkout in a fresh worktree. Auditing it would have produced findings against a state nobody runs — and would have missed a whole merged feature. Move to the real HEAD first, and keep the gitlink out of the commit (see [`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — `M user` is normal and must not be "fixed"). ## Why This Matters **An index describing another document's role makes a factual claim that can rot — and it is worse than the stale document itself.** The single most misleading line in this tree was `docs/working/README.md` advertising `summary.md` as *"Project summary / current state"*, while `summary.md` described Leaflet, `/tracker`, `/map` and `/stats`. A stale document is survivable — a reader may notice the date, the tone, the odd claim. An index that vouches for it as authoritative **defeats that judgement before it engages.** When writing an index, treat every "what this file is for" phrase as an assertion with an expiry date. **Wrong beats absent, again — now for commands.** The tiering doc found this for paths: an absent fact makes an agent go look; a wrong one makes it act confidently in the wrong place. The same held for `README.md`'s server runbook, where every `remote-*` command was documented without the `-test`/`-prod` suffix `guard-env` requires. Every documented command failed on the first line. `deploy-cycle.md` had the rule right the whole time — the defect was a **second copy** of the knowledge drifting from the first. Fewer copies would have prevented it outright. **Promoting a doc to "the authoritative list of X" creates a completeness obligation it did not have as prose.** `CLAUDE.md` pointed at `README.md` for "the full `make` command list"; README then held 7 of ~20 `remote-*` targets. The pointer was added by a well-intentioned earlier tiering pass. Routing content out of an always-loaded file is right, but **the destination inherits a duty to be complete**, and nothing enforces that. **Deliberate removals leak.** `travel-memories` was extracted to its own project, its source deleted and `services/` gitignored — but `docker-compose.yml` still declared the service, and `CLAUDE.md` still claimed it ran on :8082. `make start` has therefore been broken on every clean checkout since, hidden only because a pre-removal Docker image stayed cached locally. **A removal is not finished when the code is gone; it is finished when every consumer and every description of it is gone too.** The cached image is the general lesson: local state can mask a breakage indefinitely, so "it works here" is not evidence. ## When to Apply - After any stretch of change that outpaced its documentation, or when asked whether the docs would survive a review - Before rewriting a stale plan, spec, or milestone — annotate it instead; the record is the value - When reversing a decision: add the ledger row and the inline note in the reversal's own commit - When writing an index, a folder README, or any "read X for Y" pointer — that pointer is a claim - When removing a service, route, feature, or dependency: sweep for consumers *and* for prose that describes it, including compose files, always-loaded instruction files, and demo fixtures - When promoting any document to authoritative for a list — decide who keeps it complete - Before auditing a repo with submodules: confirm you are on the state that actually runs ## Examples ### Tense-marking a historical spec, without rewriting it `milestones/milestone-2.md` still opens with its original goal — that is the record. The banner sits directly beneath it, so the stale claim cannot be read alone: ```markdown **Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, … > **Superseded — written 2026-06-21. Neither the `/map` page nor Leaflet exists.** > > - **No `/map` route.** The map renders inline on the trip page via the single shared partial > `templates/partials/entry-map.html.twig` (R1, retired 2026-07-04). > - **Leaflet + OpenStreetMap tiles → MapLibre GL JS** (R4, 2026-06-20). > > The *substance* of this spec survived — markers per entry, chronological route line, popups, > bounds fitting — it all lives in `MapUtils.initEntryMap()`. Only the page and the library changed. ``` Separating "the idea won" from "this implementation lost" is what stops a future reader concluding the whole spec was a dead end. ### An index that vouched for a stale document ```diff -| `summary.md` | Project summary / current state | +| `summary.md` | **Historical** wrap-up of the original four-milestone branch (2026-06-21). + *Not* the current state — for that read [`../reference/architecture.md`](../reference/architecture.md) | ``` ### A source relationship that never existed `CLAUDE.md` asserted a build dependency between two unrelated things. `css/` is hand-authored and served *directly*; `css-compiled/` is esbuild output from the CSS imports inside `js/src/*.js`: ```diff -- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`) +- `css-compiled/` and `fonts/` are **esbuild output from the imports inside `js/src/`** — *not* + from `css/`. Everything in `css/` is hand-authored and served directly (`assets.addCss` in + `partials/base.html.twig`), never compiled. ``` The failure this invited: an agent wanting to change a font edits `css-compiled/main.css` — a generated bundle — because the rule named `css/style.css` as its source and that file does not contain it. The next `make build-assets` silently reverts the edit. ### Proving a breakage instead of inferring it Reasoning that a missing directory *would* break a build is not evidence. Running it is: ```console $ docker compose build travel-memories unable to prepare context: path ".../services/travel-memories" not found ``` The follow-up mattered more than the failure: a cached `travel-blog-intotheeast-travel-memories:latest` image explained why `make start` still worked on the main checkout but failed in every new worktree. Without that check the finding would have been reported as "broken everywhere" and been wrong. ## Related - [`claude-md-content-tiering.md`](claude-md-content-tiering.md) — the content-type tiering axis and the "descriptions drift, rules don't" thesis this learning extends with a tense axis. **Consolidation candidate:** the two overlap on root cause and on the files they touch; if a third documentation learning appears, consider merging all three into one documentation-maintenance doc. - [`../architecture-patterns/retiring-a-consolidated-grav-sub-page.md`](../architecture-patterns/retiring-a-consolidated-grav-sub-page.md) — the mechanics of the retirement that produced ledger rows R1, R2 and R5. That doc covers removing the *page*; this one covers removing the *claims about* the page. - [`../architecture-patterns/dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — why a fresh worktree's `user/` sits at the pin rather than at HEAD, which is the audit-baseline trap in §6. - [`../integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — the same rot in the deploy-config domain: a version number that went stale and broke an install. - `docs/working/specs/2026-07-25-docs-reconciliation-design.md` — the design and the verification table for this pass. - `docs/working/2026-07-25-doc-drift-recommendations.md` — the code-side findings deliberately not acted on, including the compose breakage and a proposed repeatable `make docs-check`.