AI

Stale docs are prompt injection you wrote yourself

On this page

An agent opens a file, sees a strange-looking loop, and "simplifies" it.

The loop was strange on purpose. Someone had chosen it months earlier, after an incident, in a meeting the agent wasn’t in. The PR looks clean, and the tests pass, but the decision is gone.

This is not a new problem. Michael Nygard wrote about it in 2011, when he proposed Architecture Decision Records. What changed is how often it happens.

A new developer undoes a decision maybe once, gets corrected, and remembers. An agent starts every session with no memory of past meetings, reviews, or hallway conversations. If a decision isn’t written down, the agent doesn’t know it exists, and it will happily "improve" the code by undoing it.

Writing decisions down is the obvious fix, and only half of one. Once an agent reads your records, they stop being documentation and become instructions. A record that’s still true protects the decision. A record that stopped being true does the opposite, and the agent can’t tell the two apart.

That’s prompt injection, except nobody attacked you. You wrote it yourself, months ago, and never went back.

At Codeminer42 we made ADRs mandatory in every project. This post is the story of how that went on one of our client projects: what went wrong when the records were unchecked, how a record is born, how we keep 160 of them from swallowing the agent’s context, and the checks that fail CI when something drifts.

Before: records nobody checked

The project started writing ADRs almost from day one. The first records landed in May 2026. Two months later, there were about 120.

That sounds like the happy ending. It wasn’t.

  • Three numbers were taken twice. Backend 0024, backend 0067, and admin portal 0002 each pointed to two different records.
  • The status column used seven phrasings for four relations. "Superseded", "replaced by", "see also", and so on.
  • Merged records were rewritten after the fact. One frontend record gained an "Updated 2026-08-28" paragraph. Five accepted records had been edited to read as current before anyone noticed. Each edit hid when the decision changed, and why.
  • Some records said one thing and the repo did another. One ADR mandated Conventional Commits. 178 of the last 200 commits used the ticket prefix instead. Another one removed Nx, and the Nx CI monitors were still there.

The decisions existed, but nothing checked them.

With people, that’s an annoyance. With agents it’s worse, because agents read these records as instructions. An agent can’t tell a paragraph that used to be true from a rule in force, so a stale ADR steers it wrong and it follows with full confidence.

Take the Conventional Commits record. An agent that read it before committing would write feat: and fix: prefixes, against what 178 of the last 200 commits on the project did. Nobody planted that instruction. The team left it there.

The team also noticed that agents open a record directly far more often than they read the index. Whatever a record says on its own, without context, is what the agent does.

What the manifesto says

Our AI manifesto has a section called We use shared practices. It says:

Architectural decisions are part of those project rules. We record them as Architecture Decision Records, so that miners and agents work from the same documented decisions instead of rediscovering them or silently undoing them.

The other line that shapes everything below comes from the same document: AI checks; humans attest. An agent can draft a record, keep the index, and flag a contradiction. A human decides what the decision is.

How an ADR is born

When to write one

The project keeps explanations out of code comments (that’s its own record, PL-0010), so the ADR is the only durable place a reason can live. That makes the rule for when to write one matter a lot.

It fits in one sentence, and it’s in the root CLAUDE.md:

Write an ADR when a change makes a choice that someone could undo without knowing why it was made.

Any one of these signals is enough:

  • you rejected an alternative someone will propose again;
  • the code looks odd to the next reader, on purpose;
  • future code has to follow a rule this change introduces;
  • a contract between apps changed.

Everything else goes somewhere else. A bug fix goes in the commit. A task goes in Jira. A convention a linter already enforces goes nowhere.

If you catch yourself (or the agent) writing a long code comment explaining why, stop. That comment is an ADR trying to escape.

The shape

The portal has four stacks: platform, backend, frontend and admin portal. Each keeps its records in its own docs/adr/ folder, and each ID carries the stack prefix: PL-0015, BE-0084, FE-0012, AP-0005.

The template is Nygard’s, trimmed:

---
status: accepted
---

# BE-0084: Count pods per organization before committing a transfer

## Context

## Decision

We will <one assertive sentence>.

## Consequences

A few rules make that shape hold up:

  • One decision per file. If the title needs an "and", it’s two ADRs.
  • The title is an imperative. It names the decision, not the topic.
  • Status and relations live in frontmatter, not in prose. A record is accepted or superseded. Relations (superseded_by, partially_superseded_by, revised_by, extended_by) are declared once, on the older record, and the index shows the inverse.
  • Merged records are immutable. Treat an ADR like a migration that already ran. To change a decision, you write a new record that supersedes the old one.

    The only edits allowed on a merged record are a relation in its frontmatter and a one-line note under the title: > Superseded by [XX-NNNN](…) on YYYY-MM-DD — one-line reason.

Who writes it

The agent writes it. A human reads it and makes sure it makes sense.

The agent was in the session where the choice was made. It knows which alternatives were tried, what broke, and what the code now does. Writing that down is cheap for it.

What it can’t do is know whether the decision is right. So the human’s job is to read the record and check it against what was actually decided. Is this the choice we made? Is the context true? Would the next person understand why, from this page alone? If not, it goes back to the agent.

The clerical part is a command the agent runs:

pnpm adr:new backend "Count pods per organization before committing a transfer"

It takes the next free number, fills the heading from the template, and regenerates the index. Nobody picks a number by hand anymore, which is how three of them got taken twice.

The hard part is not clerical. A record the agent writes from a vague request is a guess dressed as a decision. So before writing, the agent should ask: what did we reject, who needs to know, what must future code do. The human’s read at the end is what turns the draft into a record.

Memory: keeping ADRs from growing forever

The more decisions you record, the better the agent’s memory gets, until the records outgrow its context and it stops reading them.

The project went from 120 records to 160. Nobody is pasting 160 ADRs into a prompt.

The analogy that helped me most: ADRs are the git log. CLAUDE.md is the working tree. You don’t read the whole log to know what the code looks like today. You read the working tree, and you dig into the log when you need to know why.

The agent works the same way. The root CLAUDE.md tells it that docs/adr/INDEX.md is "the map of the decisions in force", and to start there to find the records a task touches. Then it reads only those records, frontmatter first.

So the index is the part that has to stay small and true.

Generate the index, never write it

An index that a person or an agent keeps by hand drifts in the first busy week.

The index is generated from the records’ frontmatter. CI regenerates it on every PR and fails if the committed one is different. If two PRs conflict on the index, you don’t resolve the conflict. You regenerate.

Spend the agent’s context on what’s in force

Then the team measured the index. The table padding alone was more than half the file.

So the generator dropped the padding. Then it dropped superseded records altogether. They "cost an agent context and give it nothing."

Someone suggested moving them to a section at the end instead. The record that rejected it has a line I keep quoting:

An agent that reads the file still reads them.

Today the index lists 156 standing records in about 2,800 words. That fits in a context window with room to spare.

Keep each record small

CI warns when a new ADR goes past 800 words. It’s a warning, not a failure. A record that long is usually two decisions, or a decision with an implementation diary attached. The diary belongs in the PR.

What doesn’t become an ADR

Most of what a team learns never becomes an ADR. It’s a gotcha, a workaround, something one agent session figured out and then lost when the session ended.

For that kind of knowledge, we use ai-memory, a long-term memory server for coding agents. Each developer runs it locally, and it captures what the agent learns along the way: decisions, gotchas, procedures.

On its own, that memory lives on one machine. So we sync it into a docs/wiki/ folder in the repo, where every developer and every agent can read it. The principle is the same as with ADRs: if it isn’t in the repo, the next agent doesn’t know it.

Deterministic drift checks

The manifesto has a line I like a lot: a plausible answer is not evidence. For ADRs, that turned into a simple rule.

A rule that nobody checks doesn’t hold.

Remember the five rewritten records. "Don’t edit merged ADRs" was a sentence in a README, and a sentence in a README doesn’t fail a build.

So now a script does. It’s scripts/adr.mjs, about 880 lines, with 775 lines of tests. It runs in its own workflow on every PR, on every push to main, and again when a PR’s base changes. It runs even on docs-only PRs, which skip the main CI, because an ADR PR is usually docs-only.

It checks three things.

1. The records against each other

The PR fails on:

  • a duplicate ID;
  • a bad status, or superseded with no superseded_by;
  • a relation that points to a missing record, or isn’t stated in both directions;
  • a heading that doesn’t read # <ID>: <title>;
  • an index that differs from the generated one;
  • a broken link, or a stack folder nobody registered;
  • any edit to a merged record’s body, or deleting or renaming one.

That last one is what makes immutability real. The script diffs every record against the base branch, so a merged record’s body can’t change, and a superseded record can’t go back to standing.

2. The code against the records

Code cites ADRs in comments. Nothing checks a comment against the code under it, so a citation can go stale without a sign.

On our project it was worse than stale. Of 289 ADR citations in the code, 225 used a number that existed in two or more stacks. When we went through them, 24 exception comments that meant backend record 3 were sending readers, and agents, to platform record 3.

So citations now use the full ID, and the script scans every .ts, .tsx and .js file under apps/ and packages/:

  • a bare number (ADR-0003) or a path fails;
  • a citation to a superseded record warns: "cite the record that stands, or drop the comment."

3. The agent instructions against the records

The same scan runs over CLAUDE.md, the README files and every skill in .claude/skills/.

These are the files an agent reads first, before any record. A dead ADR reference in a skill is the most expensive kind, because the agent follows it every single session.

What a script can’t check

None of that reads the meaning of an ADR.

A script can tell you BE-0091 exists, is accepted, and is cited correctly. It can’t tell you that a new worker breaks what BE-0091 says the worker must do. It can’t check the commit format either, or that an env var follows the record that names it.

That’s review. On this project, a lot of the review is done by Chicken Little, the team’s agent, which runs our review skill on PRs. I run it and read what it says before anything is posted.

The review skill has an ADR section. It tells the reviewer to flag a choice made with no record, a record that states no choice, and code that contradicts an accepted record. And it says to read the records covering the code the PR changes, not only the ones the PR cites. That last part matters most, because the PR that breaks a decision usually doesn’t cite it.

Here are three of those reviews. In all of them the tests passed and adr:check was green, because every record was fine. It was the code that had drifted.

The first one blocked the PR. BE-0091 wants the charge worker to crash when the payment service is misconfigured. The PR started a second worker, for exports, behind the same check, so nobody could run an export on their machine anymore.

Chicken Little requesting changes because the export worker now refuses to start without the payment server URL, a crash BE-0091 only wants for the charge worker

In the second, the drift was in the UI copy. The skip dialog told reviewers their reason was saved with the transaction. BE-0087 says the backend stores nothing: it emails the managers and puts the transaction back in the queue.

Chicken Little pointing out that the skip dialog promises the reason is saved, while BE-0087 says the backend stores nothing against a skip

The third is the quietest. A cleanup PR deleted a code comment that held a constraint from FE-0014: the pod list asks for one page of 1000 rows, so lowering that ceiling breaks it. The review asked to keep the constraint, citing the record.

Chicken Little asking to keep a comment that held the FE-0014 page-size constraint

In all three, the reviewer found the gap by reading the record next to the code.

The review catches the other direction too. On another PR, a change extended BE-0045 but didn’t say so. The review asked for extended_by in the record’s frontmatter, and the author added it in the next push. From then on, the script checks that relation.

The order matters. The deterministic checks come first and cover everything they can. The agent covers only what’s left: does this code do what the decision says? Neither one approves the PR. A human does.

The next layer

There’s one more step we haven’t taken on this project yet: turning the checkable parts of a decision into lint rules.

Some decisions are really dependency rules. "The domain doesn’t import the framework." "Module A doesn’t reach into module B’s internals." "export * is forbidden." Those don’t need an LLM to check. They need ESLint or dependency-cruiser, with the rule pointing back to the record that created it.

If I were building it, I’d copy three ideas:

  • A baseline that only shrinks. Most codebases can’t go from "decided" to "zero violations" in one PR.

    So you record today’s exact violations. A new one fails. A fixed one fails too, until you shrink the baseline, so the count can never quietly grow back.

  • A canary for every rule. A linter is easy to make vacuous.

    A dependency checker that cruised zero modules exits 0, and it looks exactly like a clean run. So each rule gets one deliberate violation it must catch.

  • Pin the rule to the record. If someone edits the decision without regenerating the rules, CI should notice.

That’s the direction for the decisions that fit in a lint rule. For the rest, there’s review, and a human.

What I’d tell a team starting today

  • Write the record when someone could undo the choice without knowing why. Not for every PR, and not as a changelog.
  • Make records immutable, and make CI enforce it. Supersede; don’t edit.
  • Generate the index, and drop what’s no longer in force. Your agent’s context is a budget.
  • Check citations, in the code and in the files agents read first.
  • Leave meaning to review, and the final call to a human.

You don’t have to start from a blank folder. We packaged the setup as a skill, adr-setup, in the Codeminer42 skills repo:

npx skills add Codeminer42/skills --skill adr-setup

It creates docs/adr/, the template, an index, and a first ADR recording the adoption itself. It also adds a section to your AGENTS.md or CLAUDE.md telling the agent to read the index before an architectural change, to follow accepted records, and to stop and ask when a request conflicts with one.

Two things it won’t do. If your repo already records decisions in any form, it leaves your practice alone. And it won’t backfill ADRs guessed from the code: a record with invented context is worse than no record.

The point of an ADR was always to stop the next person from undoing a decision they didn’t know about. The next person is now, more often than not, an agent. It reads everything and remembers nothing. Whatever you want it to know has to be written down, and whatever you wrote down has to be checked, because it follows the stale parts too.

Thanks for reading!

We want to work with you. Check out our Services page!

We want to work with you

The engineers who write here are the same ones who join client teams. Let’s build something that grows with your business.

Book a call