Architecture Decision Records
An ADR records a decision that shaped this codebase, and — more importantly — why, so that a future reader can tell whether the reasoning still holds.
These are living documents
Section titled “These are living documents”An ADR here is not a contract, a sign-off, or a historical artefact. It is the current best understanding of a problem and the solution we have chosen for it. Understanding changes. When it does, the ADR changes with it.
This is a deliberate departure from the original ADR convention, where an accepted record is immutable and a change of mind means writing a new record that supersedes it. That model optimises for an audit trail. We are optimising for a document that is true today, because the main reader is someone — or some agent — trying to work out how the system fits together right now, and a directory of mostly-obsolete records serves them badly.
None of which makes changing one free. A decision that code has been built on has a price to revise: work to redo, clients to migrate, sometimes data to move. That price is real and should be named rather than discovered.
But a price is not a veto. If someone has a good reason and is willing to pay the cost, that is the system working — the record exists to make the cost visible so the trade can be made deliberately, not to make the answer permanent. Every record therefore carries a Cost of change section alongside What would change our mind: one names the trigger to reconsider, the other names the bill.
So:
- Edit ADRs in place when your understanding improves. Add a line to the Revisions section saying what changed and why.
- Reversing a decision is normal, not a failure. It means you learned something. Record what you learned.
- Weigh the cost, then decide. The bar is a good reason and a willingness to pay, not consensus or seniority.
- Watch for asymmetry. Many decisions here are cheap to widen and expensive to narrow again. Those are the ones to change slowly, and the Cost of change section calls them out.
- Write them early, while the reasoning is fresh and before the decision feels inevitable. An ADR written after the fact tends to justify rather than explain.
- Do not add process. No approvals, no quorum, no status meetings. If you changed something architectural, write down why.
Only write a new record instead of editing when the problem changed, not the answer — that is genuinely a different decision, and the old one stays as context with its status set to Replaced.
When to write one
Section titled “When to write one”Write an ADR when a choice is hard to reverse, has real trade-offs, or will otherwise be re-litigated every few months by someone who does not know why it went the way it did. Signals: you argued about it, you rejected a reasonable alternative, or the answer is going to surprise someone.
Do not write one for choices with an obvious default, or for things the code already says plainly. Those belong in a comment.
Status
Section titled “Status”Status describes maturity and confidence, not approval:
| Status | Meaning |
|---|---|
| Exploring | Direction chosen, not yet built enough to trust. Expect movement. |
| Working | Implemented, and holding up in practice so far. |
| Revisiting | Something has challenged it. Actively being reconsidered. |
| Replaced | The problem changed. Points at the record that replaced it, and stays for the reasoning trail. |
There is deliberately no “Accepted” and no “Final”.
Confidence is separate from status and is about how much evidence we have: High (built it, used it, it held), Medium (built it, limited use), Low (reasoned about it, have not felt the consequences yet). Being honest here is the whole point — an ADR marked Working / Low confidence is a useful signal, not an embarrassment.
Reviewing
Section titled “Reviewing”Each record carries a Last reviewed date. When you touch an area, glance at its ADR: if it still reads true, bump the date; if it does not, fix it. An ADR whose review date is far behind the code is a stale document, and stale documents are worse than absent ones.
The What would change our mind section is where a record earns its keep. It names the observation that should trigger a revisit. If you hit one of those conditions, that is your cue to reopen the record — not to work around it quietly.
Writing one
Section titled “Writing one”Copy 0000-template.md, take the next free number, and use
a short kebab-case slug: 0013-cursor-pagination.md. Numbers are allocated in
order and never reused.
Keep it short. A page is usually enough, and a record nobody finishes reading has failed at its only job. The sections are Context, Decision, Consequences, What would change our mind, Cost of change, Revisions — and that is the whole set. There is deliberately no Alternatives considered: a record is here to say what we are doing and what it costs, not to re-argue the case against every path not taken. Where a rejected option is genuinely close, name it in a line inside the section it bears on, and move on.
| # | Title | Status | Confidence |
|---|---|---|---|
| 0001 | Target Postgres only | Working | High |
| 0002 | Queries are values, not statements | Working | High |
| 0003 | One predicate AST, two producers | Working | High |
| 0004 | Schema is a Go DSL, and codegen flows from it | Working | High |
| 0005 | The query engine is reflective, not generated | Working | Medium |
| 0006 | Column capabilities are opt-in | Working | High |
| 0007 | One generic handler, OpenAPI generated per resource | Working | Medium |
| 0008 | Hooks are the domain-logic seam | Working | Medium |
| 0009 | A generated typed column facade; predicates stay untyped | Working | Medium |
| 0010 | Code generation is optional | Working | Medium |
| 0011 | Rejections name what would have been accepted | Working | High |
| 0012 | Change notification via a transactional outbox † | Exploring | Low |
| 0013 | No public/internal package split | Working | Medium |
| 0014 | Migrations by diff, adoption by import | Working | High |
| 0015 | Modules own their tables, and do not reference each other’s | Working | Medium |
| 0016 | A guard is not trusted until it has failed on purpose | Working | High |
| 0017 | An enum is text with a CHECK constraint | Working | Medium |
| 0018 | Repository tooling operates on the files git tracks | Working | Medium |
| 0019 | Connections go through PgBouncer, except the ones that cannot | Working | High |
| 0020 | The transaction handle is built now, not with Go 1.27 | Working | Medium |
| 0021 | A hook gets a transaction, not an event | Working | High |
| 0022 | A reference declares the name its target knows it by | Working | Medium |
| 0023 | A mixin contributes columns; carrying behaviour needs codegen | Working | High |
| 0024 | No annotation slot until something can consume one | Working | Medium |
| 0025 | Expansion is one statement, and Hidden survives the join | Working | Medium |
| 0026 | A vector column declares its index, and search is its own operation † | Working (column) / Exploring (index) | Medium |
| 0027 | A page is a position, not a distance | Working | High |
| 0028 | The TypeScript client is generated from the model, and stops at the query key | Working | Medium |
| 0029 | The CLI is generated too, and its help text is the type system | Working | Medium |
| 0030 | A declaration that rows are confined is an obligation, not a comment | Working | High |
| 0031 | The Dart client keeps the vocabulary and gives up the narrowing | Working | Medium |
| 0032 | The command compiles a driver, and the project declares itself in Go | Working | Medium |
| 0033 | An array is its element type plus a flag, and the slice stays plain | Working | High |
| 0034 | A row is addressed by one column, and a composite key becomes a unique index | Working | Medium |
| 0035 | A type override changes the Go type and nothing else | Working | High |
| 0036 | The wire spells a column the way the schema does | Working | High |
| 0037 | ?search is ILIKE, and a tsvector column is not in 1.0 † |
Working | High |
| 0038 | A collection has one path, and the parent is a filter | Working | High |
| 0039 | A schema edit is an API edit, and the break is diffed | Exploring | Medium |
| 0040 | The driver is a dependency, not a seam | Accepted | High |
| 0041 | A computed field is an expression in the row, and the parameterised ones oblige a hook | Working | Medium |
| 0042 | The exit is generated, and what it does not carry is named | Working | Medium |
| 0043 | A declared action generates the envelope, and the verb stays plain Go | Working | Medium |
| 0044 | The container is an adapter, and the adapter is glue to copy | Working | High |
| 0045 | The stream is a seam, and its first source is honest about being in-process | Working | Medium |
| 0046 | A negation is SQL’s, and the alternative is a second vocabulary rather than a redefinition | Working | High |
| 0047 | There is no default hook registry, and the short name takes the registry | Working | High |
| 0048 | An auto-incrementing key is a property of the column, and both of Postgres’s spellings are declarable | Working | High |
| 0049 | The agent skill is generated where it can be gated, and static only where no check is possible | Working | Medium |
| 0050 | Reachability is a property of the mount, so one table can serve a public surface and a privileged one | Working | Medium |
| 0051 | A gap below the declaration is reported, not silent | Working | Medium |
† Deliberately not in 1.0. The decision is recorded; the feature is out of scope for the first tag. The road to 1.0 says why for each. Scope is not a status — a record can be Working as a decision and unbuilt as a feature, which 0037 is.
No record now obligates work before the tag. 0040 did — it breaks Executor,
which compatibility.md freezes, so it was land-before-1.0-or-never — and it is
built.