Architecture Decision Records
An architecture decision record (ADR) holds the why behind a design: the
problem it answers, the choice made, and what that choice rules out. The rest of
the documentation says what the server does. The module docs (//!) say what
each piece of code guarantees. The ADRs are where the argument lives, so that
the argument is not restated wherever the decision is mentioned.
Read the relevant ADR before changing something it covers. If a change reverses a decision, write a new ADR that supersedes the old one and change the old one’s status. Do not rewrite its argument after the fact.
| ADR | Decision | Status |
|---|---|---|
| 0001 | Before 1.0.0, only the database schema is a compatibility promise | Accepted |
| 0002 | One binary over a layered workspace of lockstep crates | Accepted |
| 0003 | Migrations are append-only and applied only by the schema owners | Accepted |
| 0004 | Row ids are UUID v7 stored as BLOBs, and their type says where they came from | Accepted |
| 0005 | A Rust enum owns each vocabulary, and SQL checks only the closed ones | Accepted |
| 0006 | A request does no slow or privileged work; it queues it | Accepted |
| 0007 | One binary runs as role processes, and only the worker holds the CA key | Accepted |
| 0008 | State that more than one process can see lives in the database | Accepted. One exception stands, the web admin’s login limiter, for as long as |
| 0009 | Dependencies are pure Rust on ring, add no global state, and earn their place | Accepted; metrics clause superseded by 0011, crypto clause narrowed by 0014 |
| 0010 | Errors derive thiserror, carry their whole message, and panic only at startup | Accepted. This was re-argued more than once before it was written down, which |
| 0011 | Metrics are built on prometheus-client, one registry per scrape | Accepted |
| 0012 | Images are built natively per architecture, uncached, and published only past a guard | Accepted |
| 0013 | main is the trunk, and a patch line is a release branch cut when a fix needs one | Accepted |
| 0014 | PostgreSQL is chosen by the URL’s scheme, over one set of queries | Accepted |
Decisions argued elsewhere
Some decisions are explained where their subject lives, and have no record here, because a second copy would drift from the first.
In the book:
- Hoisting the JWS checks into an extractor, and the two security properties it must keep.
- Pluggable signing keys:
rcgen’s ownSigningKeyas the seam, and signing on the blocking pool. - Secrets are stored three different ways, on purpose
- Columns nothing ever compares against: no identity is pinned to an address or a User-Agent.
- Evidence has no foreign keys: the audit trail and the revocation ledger outlive what they describe.
- Profiles: an endpoint is a profile, its path is derived from its name, and it is a database boundary.
- Bypass is not a shortcut: why validation is on by default.
- When a check cannot decide: the filter’s three-valued answers.
- Reloading the configuration: a reload is a rebuild and a swap, all or nothing.
- Why a second listener, and the web admin’s CSRF, roles and read-only audit view.
- Why the audit trail survives deletion.
- Delivery semantics of notifications.
- Paging: every listing is paged and says so.
In a module’s own documentation (//!), where the decision concerns that
module alone:
crates/jobs/src/jobs/mod.rs: why background work is a durable queue and not atokio::spawn, and theRetry/Failedsplit every handler must honour.crates/signer/src/local_ca/crl.rs: how the CRL number stays monotonic across processes.crates/policy/src/filter/policy.rs: Kleene logic, and why a rule’s stages are an intersection.crates/policy/src/ipam/mod.rs: why an inventory never denies, so an outage cannot fail open.crates/core/src/script_hook.rs: the one contract everycustomscript runs under.crates/core/src/templating.rs:.htmlescapes and.j2does not, decided by the name.crates/jobs/src/metrics.rs: bounded cardinality, families declared even when empty, and counters that survive a reload.crates/jobs/src/notify/expiry.rs: why the expiry notice is a digest.crates/server/src/reload.rs: the swap’s mechanics and what stays frozen.
Writing an ADR
Name the file NNNN-kebab-title.md, taking the next free number, and list it
both in the table above and in SUMMARY.md under this page. doc/lint.py
refuses an ADR that is missing from either list, or that lacks one of the five
sections below.
The title is # ADR NNNN: <decision>, and the sections come in this order:
## Status:Accepted, orSuperseded by ADR NNNN. Name a proposal to replace it if one is on record.## Context: the problem, and the history that makes the rule necessary. History with no rule behind it does not belong here — git andCHANGELOG.mdalready keep it.## Decision: what was chosen, stated as rules.## Consequences: what the decision costs and what it rules out.## Enforced by: the tests, startup refusals and type-level constructs that hold the decision in place, named so that a grep finds them. Write “Review only” when nothing does.
Link to the page that owns a configuration key rather than restating its default, since the book documents each key in exactly one file.