Skip to main content
CodeMiner launches in 2027. The pilot program is open now →
← all notes

Decisions that outlive the session

Coding agents remember little from one session to the next. The code tells them what a system does, but not why, and not which ideas were already tried and dropped. A decision log fills that gap: one file per decision, an index, a short section in the agent's instructions, and a check. How it worked while agents built a 3D space game, where it helped, where it hurt, and the instructions to copy into your own project.

published
author
Hendrik

A coding agent usually starts a session with little or no memory of the last one. Some tools keep notes between sessions, but those notes belong to one user, live outside the repository, and are hard to inspect. The code does not fill the gap either: it shows what the system does, but not why it does it that way, which alternatives were weighed and dropped, or what the person directing the work asked for last week.

Without those reasons, agents make three kinds of mistake. They "fix" deliberate choices that look wrong, such as a value that contradicts a library's default. They propose ideas that were already tried and turned down, because code only shows what was built. And they change a rule without knowing what else was built on the assumption that it holds.

The remedy is old. Architecture decision records (ADRs), short documents that each capture one decision, go back to a 2011 post by Michael Nygard, and the architecture-decision-record repository collects templates and examples for them. What changes with agents is who writes them and who reads them. The agent writes the record as part of the change, and the next session, whatever it remembers, reads it before touching the code it covers.

We used this pattern while AI agents built Solar Explorers, a 3D space game that runs in the browser and is free to play at explorers.orrery.games. By the end, the agents had written 276 decision records.

The Solar Explorers trailer, rendered in the game's own engine.

When you need one

Not every project does. A typical CodeMiner app starts from an explicit architecture contract: what the system does, its data model, its API, and its milestones, all written before the first line of code. The decisions are made up front, the contract holds them, and the agents building the system are held to it. Solar Explorers' first version was built exactly that way.

A decision log earns its cost when decisions keep changing during development, and a game is a clear case. It is complex, it is built toward a vision that can change along the way, and its details change all the time. Most of its decisions are made long after any contract could have been written, and each one needs a place where the next agent will find it.

If a full log feels heavy, lighter tools cover part of the job:

  • Comments can record a decision too, but in a large project the same reasoning ends up copied into many of them, and keeping the copies in sync is hard. A decision log gives the reasoning one home, and the comments only point at it.
  • Commit messages hold the reasons for one change, but nothing tells an agent to look there, and the reasons behind a rule end up spread over many commits.
  • The instructions file works for a handful of rules, but the agent reads all of it in every session, so it cannot hold hundreds of them.
  • An agent tool's own memory, where it has one, is not versioned or reviewed with the code it describes.

With a log or without one, the most useful addition is a test or a lint rule that enforces a decision: breaking it then fails the build, and the record explains why the check exists. In Solar Explorers, hundreds of test files cite the record they protect.

What works

Deliberate choices stay deliberate

Every codebase has choices that look wrong until you know the reason. A record puts the reason next to the choice, and the code points at it, so the next agent reads the reason instead of "fixing" the choice. In Solar Explorers, a star's light weakens with distance, not with the square of distance as real light does, because the game's orbits are not drawn to scale, and on them the square law turned the innermost planet white and the outer ones black. The config line cites the record, and a test fails if anyone sets the falloff back.

Rejected ideas stay rejected

Code shows only what was built. The alternatives that lost, and who turned them down, live nowhere else, and a rejected option often looks like exactly the obvious improvement an agent will propose next. A record says that the idea was weighed, why it lost, and when to reconsider it. In Solar Explorers, a websocket was turned down because it would have added a second server runtime to solve a problem the game did not have yet, and its record names the day to bring it back.

You stop repeating yourself

Without records, the person directing the work restates the same choices in every session, or finds them quietly undone. A record turns a choice made in a conversation into something every later session, agent or person, can read and review. It also makes the agent's own reasoning visible: you can see why it built something one way, not only that it did.

Changes know what they affect

Code and tests that rely on a decision cite its record, so a search for the record's number finds everything a change to the decision would touch. When a decision is reversed, the new record names the records it amends, and the index links back, so the history of a rule is a chain to follow rather than a guess. Because each record is small and the index states every decision in one sentence, an agent can load just the reasons it needs, without reading the whole repository or its history.

What doesn't

It costs something on every change

A useful record takes real work: the decision, the reasons, the consequences, and the alternatives that lost. Agents will write one for nearly every change unless they are told when not to. Reading costs too: every record an agent reads before a change takes room in its context, and after a few hundred records the index is too long to read whole, so agents have to read it one area at a time.

Records go stale

A record describes the code as it was when it was written, and the code keeps moving. A test can check that every citation points at a record that exists, but not that the record is still true. Someone has to reread them now and then, correct what has moved, and write a new record where an old one no longer holds.

The current rule fragments

Because records are amended rather than rewritten, a rule that changes often ends up spread over a chain of records, and knowing what holds today means reading the whole chain. When a chain grows long, a consolidating record that states the rule as it stands keeps it readable.

Rules leak into other places

Agents tend to repeat a record's rule wherever they know the next session will look: the instructions file, a comment, a docstring. Each copy is a second home for the rule, and copies drift apart. The instructions have to forbid it, and a check helps.

The instructions

This is the section to put in your AGENTS.md or CLAUDE.md, the file your agent reads at the start of every session. It names both files, so it works in either. The last two rules assume a check at tests/test_decision_records.py, like the example at the end of this post; if yours lives elsewhere or has no size budget, change those lines to match. It is short on purpose: the conventions and the template live in docs/DECISIONS.md.

## Decision records

A decision record explains one decision whose reasons the code does not show:
what was decided, why, and which alternatives were rejected, and by whom.
Records live in `docs/decisions/`, one file per decision, named
`NNNN-a-few-words.md`. `ADR-NNNN` anywhere in the repository cites the record
with that number. `docs/DECISIONS.md` holds the conventions, the template, and
the index: one row per record, grouped by area.

- **Read before you change.** Before changing code that cites a record, read the
  record and every record that amends it, directly or through another
  amendment; the _amended by_ notes in the index list them. Then search the
  repository for the record's number: every citation marks code or a test that
  relies on the decision. Before changing behaviour in an area, read that
  area's rows in the index. Do not propose an option a record rejected unless
  something has changed since, and then say what. If a record says the user
  made the decision or rejected the option, only the user can reverse it.
- **Write one when a reader would get it wrong.** A change needs a record when a
  competent reader would take it for a mistake; when it departs from a library
  or framework default, from the project's documentation, or from an earlier
  record; when it rejects an option that is likely to come up again; or when the
  user makes a decision in conversation that the code will not show. A bug fix,
  a refactor, or a feature built the obvious way does not. Write the record in
  the same change as the code: copy the template, take the next free number,
  title it with the decision as a sentence ("Sessions expire after 30 days
  without use", not "Sessions"), and add a row with that title to the index.
- **Cite, don't copy.** A decision's reasons live in its record and nowhere
  else. A comment at the code that implements the decision says what the code
  does and cites the record, as in `# Expire idle sessions (ADR-NNNN).` A test
  that enforces the decision cites the record too. Neither repeats the reasons:
  two copies drift apart, and no test can tell which one is current.
- **Amend, don't rewrite.** Never change what an accepted record decided;
  correcting a stale path or name in it is fine. A change of mind is a new
  record whose status line ends with `_amends ADR-NNNN_`, and the amended
  record's row in the index gets `_amended by ADR-NNNN_`. When finding out what
  a decision says today means reading more than four records, write one record
  that states it in full and amends all of them.
- **Keep the check green.** `tests/test_decision_records.py` fails when a
  citation names no record, when the index and `docs/decisions/` disagree, or
  when `AGENTS.md` or `CLAUDE.md` outgrows its byte budget. Fix the citation,
  the index, or the oversized file, not the check. Raise the budget only when
  the user asks.
- **Keep `AGENTS.md` and `CLAUDE.md` short.** They say how to work in this
  repository and where to look. A decision belongs in a record: these files may
  name the record, but do not restate it. When the check reports either file
  over its budget, move decisions out of it into records.

An agent applies an instruction as written, so each rule names the files it means and puts a number on its thresholds: "a handful of amendments" would leave the agent to guess. Why each rule is there:

  • Read before you change. A record helps only if it is read before the change, and a later record may have changed what it says, so the amendments are part of the reading. The search answers the third mistake from the start of this post: every citation it finds is code or a test that relies on the decision. The index rows catch the decisions that no code cites. The last sentence keeps an agent from overturning, on its own, what the user decided.
  • Write one when a reader would get it wrong. Without a record, the first three cases lead to the first two mistakes from the start of this post: a choice that looks wrong, or departs from a default, gets "fixed", and an idea that was already turned down comes back. The fourth saves the user from restating a choice in every session. The exception matters as much as the cases: without it, agents write a record for nearly every change.
  • Cite, don't copy. A citation can be checked, and a copy of the reasons cannot. The example shows how much a comment should carry: what the line does, and where the reasons are.
  • Amend, don't rewrite. A reversal is a new record that names what it overturns, and the index points from the old record to the new one, so the history survives. The consolidating record keeps the current state readable. Four is a judgement call: pick your own number, but pick one.
  • Keep the check green. The check keeps every citation pointing at a real record. Without the last two sentences, an agent facing a failing check may loosen the check instead.
  • Keep AGENTS.md and CLAUDE.md short. The agent reads one of them at the start of every session, so that is where agents copy a rule to make sure the next session sees it. The budget turns that growth into a failing check.

The format and a check

The index, docs/DECISIONS.md, also carries the conventions and the template, so they are in place before the first record is written. This is the file on day one, before it has any records:

# Decision records

Short records of decisions whose reasons the code does not show. One file per
decision, under `decisions/`, named `NNNN-a-few-words.md`, numbered in the order
the decisions were taken and never renumbered. This file is the index: a
citation of the form `ADR-NNNN`, anywhere in the tree, resolves through the
list below.

## Conventions

- **The status line** is `**Status:** accepted · **Date:** YYYY-MM-DD`, with
  `_amends ADR-NNNN_` appended when the record changes an earlier one.
- **An index row** is
  `- **[ADR-NNNN](decisions/NNNN-a-few-words.md)** — The record's title`,
  under the heading of its area, with `<br>_amended by ADR-NNNN_` appended once
  a later record amends it.
- **Amending, not rewriting.** An older record stays as it was decided. What
  changed is a new record that says so, and the older record's row gets the
  _amended by_ note.
- **`adr-check: ignore`** on a line that mentions an ADR number without citing
  one, such as a plan proposing the number the next record would take.

## Template

```markdown
# ADR-NNNN — A sentence that says what was decided

**Status:** accepted · **Date:** YYYY-MM-DD · _amends ADR-NNNN_

### Context

What was true before, and what forced a choice. This is a historical record:
when it names something that has since moved, correct it in place rather than
rewriting what happened.

### Decision

What was decided, who decided it, and the constraint behind each part of it.

### Consequences

What follows, including the failure modes a reader could not derive from the
code, which are most of what a record is worth.

### Revisit when

Optional. The condition under which this should be reopened.

### Alternatives considered

Every option that was weighed, why it lost, and who decided. Nothing else in
the tree records this, and it is what a future reader most often comes for.
```

## The records

One `###` heading per area of the system, with that area's rows under it.

Alternatively, you might want to name records by an ISO date and a lowercase, dash-separated slug instead of a number, such as 2026-10-23-hold-the-music-on-a-road.md, because dates keep the records in order even when teams combine projects into a monorepo or pull in a dependency with its own records.

The template asks who decided twice, for the decision and for each alternative, because the instructions treat the answers differently: an agent may reopen another agent's call once something has changed, but only the user can reverse the user's.

A small decision gets a small record. In the game, the music follows the danger around the pilot, and a nearby planet, moon, or star counts as danger. Auto-lanes, which the game calls roads, carry a ship past all of them without harm, so the music kept switching to its warning track and back for nothing, and the user asked for it to stop. The record below holds that request, the decision with its mechanism, what follows, and the alternative that lost. Its context cites three other records instead of explaining them again:

# ADR-0276 — The music holds while a road carries the pilot

**Status:** accepted · **Date:** 2026-10-23 · _amends ADR-0074_

### Context

The music follows the pilot's situation (ADR-0074), and a world, a moon or a
star near the pilot is a hazard to it. A road runs past worlds, round stars
and through the sky every moment of a ride, so the music swung to the warning
track and back as the road went by things — for a pilot who could not be hurt
by any of them: a hull a road carries touches nothing (ADR-0102), and no
star's light burns it (ADR-0268). The user asked for the music not to change
for as long as the pilot is on a road.

### Decision

**The situation scored as the pilot boards is scored until the road lets them
go.** `MusicSituationSensor` latches what it would score on the first read of
a ride and returns it while `pilotRiding` holds; the latch is let go when the
ride ends, and the pilot's situation is measured again from there. A story
mood still outranks it.

Held whole, not just its nearness: a fight's hold running out on the road does
not drop the music to cruise halfway down it either.

### Consequences

- Boarding near a world keeps the warning track for the ride; it comes off at
  the far end if nothing there is near.
- What the sensor is told on the road — a hit, a crew turning on the pilot —
  is still stamped, so a ride that ends in a fight ends scored as one.
- A raider knocking the pilot off a road ends the ride, and with it the hold.

### Alternatives considered

- **Leave out only nearness on a road.** Keeps a fight's music honest, but a
  fight's hold running out mid-ride still changes the track, which is what
  was asked against.

Last, a check. The one below is a single pytest file, but it is only an example: the same checks work in any language, test runner, or CI step. It fails on a citation without a record, on an index that disagrees with docs/decisions/, and on an AGENTS.md or CLAUDE.md over its budget.

"""Every ADR-NNNN in the tree names a record, and the index lists every record."""

import re
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
RECORDS = ROOT / "docs" / "decisions"
INDEX = ROOT / "docs" / "DECISIONS.md"

# Where citations can appear, and in which files. Adjust both to your tree.
SCANNED = ["src", "tests", "docs", "AGENTS.md", "CLAUDE.md"]
SUFFIXES = {".py", ".ts", ".tsx", ".md"}
SKIPPED = {"node_modules", "dist", "__pycache__"}

CITATION = re.compile(r"ADR-\d{4}")
HEADING = re.compile(r"^# (ADR-\d{4})", re.MULTILINE)
ROW = re.compile(r"\[(ADR-\d{4})\]\(decisions/([^)]+)\)")
IGNORE = "adr-check: ignore"  # on a line that mentions a number it doesn't cite

# AGENTS.md and CLAUDE.md stay short. Set the budget a little above their size
# today, and raise it on purpose, never to get a build green.
INSTRUCTIONS = ["AGENTS.md", "CLAUDE.md"]
BUDGET = 40_000  # bytes


def records() -> dict[str, str]:
    """Map each record's number to the file that defines it."""
    found = {}
    for path in RECORDS.glob("*.md"):
        for number in HEADING.findall(path.read_text(encoding="utf-8")):
            found[number] = path.name
    return found


def citations() -> list[str]:
    """Return 'file:line ADR-NNNN' for every citation in the scanned tree."""
    paths: list[Path] = []
    for entry in SCANNED:
        path = ROOT / entry
        paths.extend([path] if path.is_file() else path.rglob("*"))
    sites = []
    for path in paths:
        relative = path.relative_to(ROOT)
        if not path.is_file() or path.suffix not in SUFFIXES:
            continue
        if SKIPPED & set(relative.parts):
            continue
        lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
        for number, line in enumerate(lines, start=1):
            if IGNORE not in line:
                sites += [
                    f"{relative}:{number} {adr}" for adr in CITATION.findall(line)
                ]
    return sites


def test_every_citation_names_a_record() -> None:
    known = records()
    dangling = [site for site in citations() if site.split()[-1] not in known]
    assert not dangling, "cites a record that does not exist:\n" + "\n".join(dangling)


def test_the_index_and_the_records_agree() -> None:
    known = records()
    indexed = dict(ROW.findall(INDEX.read_text(encoding="utf-8")))
    assert indexed == known, "docs/DECISIONS.md and docs/decisions/ disagree"


def test_the_instructions_stay_within_budget() -> None:
    for name in INSTRUCTIONS:
        path = ROOT / name
        if path.is_file():
            size = path.stat().st_size
            assert size <= BUDGET, f"{name} is {size} bytes; move decisions to records"

Summary

A decision log gives a coding agent what the code cannot: the reasons behind it, and the ideas that were already turned down. It pays off when decisions keep changing during development, and is unnecessary when a contract settles them up front.

To set one up:

  1. Create an empty docs/decisions/, and a docs/DECISIONS.md from the day-one file above.
  2. Add the instructions section to your AGENTS.md or CLAUDE.md.
  3. Add a check to your test suite.
  4. Write the first record for the next decision whose reason the code cannot show.

After that, keep it lean: one decision per record, the reason in the record and only a pointer in the code, and a consolidating record once a decision is spread over more than four records.