Six months after a decision, the only people who know why the system works this way have left or forgotten. A new engineer sees an odd constraint, reasonably concludes it is legacy, and removes it — and rediscovers the reason it existed in production, usually on a Friday.
Two lightweight practices address this from opposite directions. Decision records preserve the reasoning for humans. Fitness functions enforce the constraint mechanically so it cannot drift while nobody is watching. Either alone is worth having; together they are how architecture stays intentional.
Record the reasoning, not the outcome
A decision record is a short document per significant choice: the context, what was decided, what was rejected and why, and the consequences accepted. It lives in the repository alongside the code it constrains, is reviewed in a pull request, and is never edited after acceptance — a superseded decision gets a new record that references the old one, so the history of reasoning stays intact.
# ADR-014: Tenant isolation via row-level security
Status: Accepted (2026-02-11) — supersedes ADR-009
## Context
Shared-schema multi-tenancy with global scopes in the ORM. Two near-misses in
2025 where a raw query bypassed the scope. Enterprise prospects are asking for
demonstrable isolation, and 'we are careful' is not an answer we can evidence.
## Decision
Enable PostgreSQL row-level security on all tenant-scoped tables. The pooled
connection sets app.tenant_id on checkout. ORM scopes remain as a fast path.
## Rejected
- Database per tenant: migration fleet cost too high at our tenant count (1,400).
- ORM scopes alone: no defence against raw SQL, which is where both near-misses
originated.
## Consequences
- ~3% query overhead measured on our heaviest endpoints. Accepted.
- Background jobs must set tenant context explicitly or they will read nothing.
This WILL bite; see the job base class.
- Enforced by fitness function FF-07 (test fails if a tenant table lacks a policy).The rejected-options section is the part people skip and the part with the most value. The next engineer to suggest a database per tenant deserves to know it was considered and priced, not to have the conversation from the beginning.
Make the constraint executable
A fitness function is an automated test for an architectural property rather than a behaviour. Module boundaries hold. No service queries another's tables. Every tenant-scoped table has a policy. Startup time stays under a threshold. The p95 of a critical endpoint stays under its budget. Each runs in CI, and each fails the build when the property is violated.
- Write a fitness function for every decision that can be checked mechanically, and reference it in the record.
- Keep them fast enough to run on every pull request; a check that runs weekly finds the violation after it has spread.
- Fail the build rather than warn. A warning in a log is a constraint that has already been abandoned.
- Cover operational properties too — image size, dependency licences, startup time — not only code structure.
- Review them when a record is superseded. A constraint enforcing a decision you reversed is worse than none.
An architecture nobody can violate is a diagram. An architecture the build refuses to violate is a system.
Keep both practices small. Decision records for choices that are expensive to reverse — perhaps a dozen a year, not one per pull request. Fitness functions for constraints you would genuinely stop a release over. The failure mode of both is enthusiasm: a hundred records nobody reads and forty checks people routinely bypass leave you exactly where you started, with more ceremony.