Architecture Decision Records
Record each decision with its change.
Record each decision with its change. Every architecturally significant decision gets the next numbered record in docs/adr/, added in the pull request that makes the change and reviewed with the diff. When a decision is reversed, its record stays and a new record supersedes it, so the folder shows which decisions still hold.
Opinion
I've been writing ADRs at work to cover my work and they make a huge difference, especially in a team. Michael Nygard's 2011 post describes a person new to a project who meets a past decision without its rationale. That person has only two choices: “Blindly accept the decision” or “Blindly change it”.1 In a team, every developer meets decisions that someone else made. LaToza, Venolia and DeLine found that 56% of the Microsoft developers surveyed rated understanding code someone else wrote as a serious problem, against 17% for a developer's own older code.2 A record in the repository gives every developer the reason first.
P8 ties the record to the change. Michael Keeling's 2022 column on ADRs in agile teams describes plain-text records kept in the code's repository and peer-reviewed like the code. In his words, “Anyone on the team could assume the architect's role by writing an ADR.”3 The team reviews the record with the change, so disagreement comes out on the pull request while the record is still Proposed.
Every agent session starts as Nygard's newcomer. Without a record, the reason for a decision is scattered across issues, chats and commit history, so the agent infers the intent from the code. A stale record misleads it the other way: Duncan Davidson reports an agent that kept an outdated storage abstraction “because an ADR still described it as mandatory”.4 The status line tells the agent which records to follow and which to read as history. P8's rule goes into the committed brief that AI2 Persistent Brief describes. The rule points the agent at docs/adr/, where the agent drafts each new record as Proposed for the team to accept.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Record each decision with its change. An architecturally significant change needs its decision record in the same pull request: the next numbered file in docs/adr/, with the options weighed and why each lost. The record stays Proposed until a person on the team accepts it. To reverse a decision, add a record that supersedes the old one. /tenet/architecture-decision-records/P8
AI eyes only
Rule: record each decision with its change. Read the governing records before changing code.
Reject: an architectural change with no record. Reject: code that contradicts an accepted record. Reject: rewriting or deleting an accepted record. Reject: an agent accepting its own record.
Generate: the next numbered file in docs/adr/ with a title, a date, Status Proposed, Context with the options weighed, Decision and Consequences. On a conflict with an accepted record, stop and ask. Supersede a record to reverse its decision.
Diagnostic: for each dependency the diff adds, cite the number of its record. A dependency with no number fails.
Why?
- A settled question has its answer on file. Tyree and Akerman of Capital One wrote that without rationale and options, stakeholders ask “the same questions again and again”. The record keeps both, so the trade-off behind a choice made under A7 Coupling First needs explaining only once.
- The reviewer reads the decision beside the diff it explains. The record and the code share one pull request and the review under P7 Short-Lived Branches with AI+Human Review takes in both.
- The log shows how long each decision held. A superseded record stays in the log and points at the record that replaced it (Fowler).
- The reasoning stays when the team changes. Nygard wrote that developers and stakeholders “can see the ADRs, even as the team composition changes over time”. Keeling and Runde's slides give the team's view: “Extremely helpful for onboarding new teammates”.
- Design authority spreads beyond one architect. After two years of records, Keeling and Runde report that two-thirds of the decision-making load had passed to the team. Of 198 open-source repositories with ten or more records, 165 had more than one author (Buchgeher and colleagues, IEEE Access 2023).
- A record is short and can take minutes to write. Keeling and Runde put the write-up at “maybe 10 minutes” once a decision is made. In a test with 33 undergraduates, Nygard's template scored higher overall than the longer MADR template (Nogueira and colleagues, EASE 2026).
- A model can check code against a written decision. Small open models judged compliance correctly in 83% to 91% of 305 human-checked cases from open-source projects in Buchgeher's sample (Su and colleagues, ICSA 2026). Accuracy fell on decisions that do not show in code.
Origins
The idea is older than the name. David Parnas and Paul Clements's “A Rational Design Process: How and Why to Fake It” (IEEE Transactions on Software Engineering, 1986) notes that most programmers regard documentation as “a necessary evil, written as an afterthought only because some bureaucrat requires it”.5 Its remedy is documentation written as if a rational process had produced the design, plus a record of every alternative rejected and why. In 1992 Dewayne Perry and Alexander Wolf defined software architecture as elements, form and rationale.
Jeff Tyree and Art Akerman, architects at Capital One, called architecture decisions “the missing link” in a 2005 IEEE Software article and gave a 14-field template for each one.6 Later that year Anton Jansen and Jan Bosch argued that part of the cost of changing an architecture comes from lost knowledge about design decisions. The paper calls the loss knowledge vaporisation.
Michael Nygard's “Documenting Architecture Decisions” (Relevance blog, 15 November 2011) gave the practice its name and a lightweight form.1 Each architecturally significant decision gets a short, numbered text file in the repository, with a title, context, decision, status and consequences. Numbers are never reused and a reversed decision keeps its file, marked superseded. Nygard asks for short records because “Large documents are never kept up to date.” The post ends with early feedback: six to ten developers rotated through projects using the records over about three months and every one reported valuing the context.
The Thoughtworks Technology Radar placed “Lightweight Architecture Decision Records” at Adopt in November 2017 and recommended source control for the records. Michael Keeling and Joe Runde's team at IBM Watson recorded over 80 decisions across more than two dozen repositories in two years and reviewed the records as GitHub pull requests.7 Andrew Harmel-Law (2021) set the records inside an advice process, where anyone can make an architectural decision after seeking advice. Each record keeps all advice given, taken or not.
Martin Fowler's bliki entry of 24 March 2026 credits Nygard with the term and notes that Nygard “did not originate the idea of a decision log”. It holds that an accepted record “should never be reopened or changed”, only superseded.8 GitHub's Well-Architected guidance of 20 May 2026 places decision records in the repository for agents to read and warns that out-of-date decisions “push agents toward former behaviors”.9 P8 keeps Nygard's five parts and the supersede rule. It takes the section order and date line from Nat Pryce's adr-tools and adds the options weighed. Each record goes through the review that P7 Short-Lived Branches with AI+Human Review gives every change.
Quotes
We make a policy of recording all of the design alternatives that we considered and rejected. For each, we explain why it was considered and why it was finally rejected.
If a decision is reversed, we will keep the old one around, but mark it as superseded. (It's still relevant to know that it was the decision, but is no longer the decision.)
Discussing and writing down design decisions is not a new idea. For as long as we've developed software, teams have described, debated, and shared their decisions. What is new is treating decisions as artifacts that the team writes down.
the act of writing them helps to clarify thinking, particularly with groups of people. Writing a document of consequence often surfaces different points of view - forcing those differences to be discussed, and hopefully resolved.
Evidence
25 external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01Names the format and the rule that a reversed decision stays on file. Its evidence is early feedback from six to ten developers over about three months.
- 02“Architecture Decision Record”SupportsAdvises keeping records in the source repository, numbered and typically a page long, with the serious alternatives listed. Repository storage does not suit decisions that span a wider ecosystem.
- 03Keeps a record of each rejected alternative and the reason it lost, so that later questions about a design have answers.
- 04Records in the repository, reviewed like code, let developers own design decisions in agile teams. It is a magazine column and reports no study.
- 05Trial in November 2016 and March 2017, Adopt in November 2017 and May 2018. Recommends source control for the records “instead of a wiki or website”. An endorsement with no measurement.
- 06After two years, two-thirds of one IBM Watson team’s decision-making load had been distributed to its members. Only about half the team wrote records routinely and one recorded risk was still forgotten.
- 07The team called the records “Extremely helpful for onboarding new teammates”, though most teammates referred to the records rarely. It is a single team’s self-report and the slides do not describe the survey.
- 08Each record in the advice process keeps the advice and the dissent in the open. The article describes a method and measures no outcome.
- 09Of 65 practitioners, 80% agreed that without documented rationale or someone to explain it, the reasons behind an existing design may stay unknown. Lack of time or budget most often kept rationale unwritten (60.5% of 81). Figures are from the survey’s 2005 technical report. Answers are self-reported, mostly from Australasia.
- 10Understanding the rationale behind code topped the list of problems, a serious one for 66% of 104 Microsoft developers. The survey covers one company, with “Somewhat agree” counted as agreement.
- 11Observed 17 developers at Microsoft over two months. Searches for the reason behind code went unsatisfied 44% of the time. Design knowledge sat with colleagues. Tasks were deferred when those colleagues were away.
- 12At one company, cooperation among teams improved after ADRs were introduced. Storage location had a “massive influence” on how useful the records seemed. The study rests on seven interviews and three months of observation.
- 13ICPC 2014: documented decisions with rationale took no extra time to use and improved understanding, with statistical significance in one of two controlled experiments and across both. Read from the abstract, which gives no sample size.
- 14EASE 2026: in a crossover experiment with 33 undergraduates, Nygard’s short template scored higher overall than MADR. The study compares two templates and has no condition without records.
- 15Vendor guidance: supersede or prune stale decision records, since stale decisions mislead agents. It reports no measured effect.
- 16EASE 2026: given a repository’s earlier records, models drafted the next record closer to the human version. The last three to five did about as well as the whole log. It measures text similarity only, on Buchgeher’s repositories.
- 17A preprint accepted at ICSA 2026: small open models checking code against a record matched human judgement in 83% to 91% of 305 cases, least often where a decision leaves no trace in code. The 109 projects come from Buchgeher’s 921.
- 18Argues that full rationale documentation is “typically too onerous for systematic industrial use”. In experiments with 75 master’s students, keeping only items needed at least half the time cut the items by about half. That favours a short record.
- 19ICSA 2024: drafting the Decision section from the context of 95 records, even the best models failed to produce the required Decision in full, “necessitating human involvement”. The models were GPT-4 and older.
- 20A preprint, with earlier versions at ICLR 2026 workshops. Context files loaded into every session raised inference cost by over 20% on average without improving task success in general. That favours reading only the records that govern a change.
- 21Records give agents the intent behind the code, but an agent may follow a stale record “even more rigidly than a human would”. It is one practitioner’s report.
- 22At the end of 2020, 921 public GitHub repositories had at least one ADR and 554 held five or fewer. Of the 198 with at least ten, 165 had several authors. The study covers open source only.
- 23A preprint: in 4,316 records from 547 projects in Buchgeher’s dataset, the considered-options section was absent or misplaced in most. It judges against MADR headings, though most of those repositories use Nygard’s template, which has no such section.
- 24“Architecture Decision Records: Adoption, Impact, and Developer Engagement in Open-Source Software”OpposesICSA 2026: across 5,800 records in 921 repositories, the links between ADR use and code quality or productivity were mostly weak. About 63% opened already accepted. The study is correlational and was read from its abstract.
- 25The first of eight groupware challenges: extra work falls on people who do not get the benefit. Shared records can decay the same way. The paper does not discuss decision records.
Twenty-five sources, three stances. The supporters are Nygard, Fowler, Parnas and Clements, with Tang, LaToza and Ko on lost rationale. The qualifiers include Falessi on the cost of full rationale and Davidson on agents that follow stale records. The case against rests on de Santana's small correlations and Grudin's analogy from groupware. De Santana, Miccio, Su and Gupta all draw on the 921 public GitHub repositories that Buchgeher found.
Examples
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
import type { Queue } from "bullmq"; import type { Rescue, VetAlert } from "./rescue"; export async function reportRescue( rescue: Rescue, alerts: Queue<VetAlert>, ): Promise<void> { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
import type { Queue } from "bullmq"; import type { Rescue, VetAlert } from "./rescue"; export async function reportRescue( rescue: Rescue, alerts: Queue<VetAlert>, ): Promise<void> { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
import type { Queue } from "bullmq"; import type { Rescue, VetAlert } from "./rescue"; export async function reportRescue( rescue: Rescue, alerts: Queue<VetAlert>, ): Promise<void> { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
import type { Queue } from "bullmq"; import type { Rescue, VetAlert } from "./rescue"; export async function reportRescue( rescue: Rescue, alerts: Queue<VetAlert>, ): Promise<void> { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
export async function reportRescue(rescue, alerts) { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
export async function reportRescue(rescue, alerts) { await alerts.add("vet-alert", { rescueId: rescue.id }); }
package.json"bullmq": "^6.4.0",
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
from rq import Queue from sanctuary.rescue import Rescue from sanctuary.vet import alert_vet def report_rescue(rescue: Rescue, alerts: Queue) -> None: alerts.enqueue(alert_vet, rescue.id)
pyproject.toml"rq>=2.12",
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
from rq import Queue from sanctuary.rescue import Rescue from sanctuary.vet import alert_vet def report_rescue(rescue: Rescue, alerts: Queue) -> None: alerts.enqueue(alert_vet, rescue.id)
pyproject.toml"rq>=2.12",
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
import org.springframework.amqp.core.AmqpTemplate; final class RescueReporter { void reportRescue(Rescue rescue, AmqpTemplate alerts) { alerts.convertAndSend("vet-alerts", new VetAlert(rescue.id())); } }
pom.xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
import org.springframework.amqp.core.AmqpTemplate; final class RescueReporter { void reportRescue(Rescue rescue, AmqpTemplate alerts) { alerts.convertAndSend("vet-alerts", new VetAlert(rescue.id())); } }
pom.xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Before the change moves rescue alerts onto a queue. Record 5 still says to call the vet directly, so the log and the code disagree.
use Symfony\Component\Messenger\MessageBusInterface; function reportRescue(Rescue $rescue, MessageBusInterface $bus): void { $bus->dispatch(new VetAlert($rescue->id)); }
composer.json"symfony/messenger": "^8.1",
docs/ adr/ 0005-call-the-vet-directly.mdunchanged (excerpt)# 5. Call the vet directly ## Status Accepted ## Decision Send each rescue alert from the form straight to the vet's booking service.
After the same change adds record 8, which supersedes record 5. Record 5 keeps its text and changes only its status.
use Symfony\Component\Messenger\MessageBusInterface; function reportRescue(Rescue $rescue, MessageBusInterface $bus): void { $bus->dispatch(new VetAlert($rescue->id)); }
composer.json"symfony/messenger": "^8.1",
docs/ adr/ 0008-queue-rescue-alerts.mdnew record# 8. Queue rescue alerts Date: 2026-04-14 ## Status Accepted. Supersedes 5. ## Context The vet's booking service is offline at night. Record 5 had the form send each alert straight to it, so alerts raised at night were lost. Retrying from the form was the other option. It would hold a volunteer on the page until the service answered. ## Decision Put each rescue alert on a queue. A worker sends it to the booking service and retries until the service takes it. ## Consequences Alerts survive an outage. The sanctuary now runs a queue and a worker. An alert can arrive late.
docs/ adr/ 0005-call-the-vet-directly.mdstatus changed## Status Superseded by 8
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| markdownlint/md043 | eslint-plugin-markdownlint | a record whose headings are not one title followed by Status, Context, Decision and Consequences, in that order. |
| markdownlint/md041 | eslint-plugin-markdownlint | a record whose first line is not a top-level title. |
| dangerfile.ts: new dependency | Danger JS | pull requests that add to dependencies in package.json with neither a new record in docs/adr/ nor a record number cited in the description. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| Name each record NNNN-title-with-dashes.md | GitHub Actions | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| Never reuse a number | GitHub Actions | two records that share a number, as when two branches each take the next one. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
eslint.config.mjsconfiguration snippet
import markdownlint from 'eslint-plugin-markdownlint';
import markdownlintParser from 'eslint-plugin-markdownlint/parser.js';
export default [
{
files: ['docs/adr/[0-9]*.md'],
plugins: { markdownlint },
languageOptions: { parser: markdownlintParser },
rules: {
'markdownlint/md041': 'error',
'markdownlint/md043': [
'error',
{ headings: ['?', '## Status', '## Context', '## Decision', '## Consequences'] },
],
},
},
];Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| markdownlint/md043 | eslint-plugin-markdownlint | a record whose headings are not one title followed by Status, Context, Decision and Consequences, in that order. |
| markdownlint/md041 | eslint-plugin-markdownlint | a record whose first line is not a top-level title. |
| dangerfile.ts: new dependency | Danger JS | pull requests that add to dependencies in package.json with neither a new record in docs/adr/ nor a record number cited in the description. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| Name each record NNNN-title-with-dashes.md | GitHub Actions | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| Never reuse a number | GitHub Actions | two records that share a number, as when two branches each take the next one. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
eslint.config.mjsconfiguration snippet
import markdownlint from 'eslint-plugin-markdownlint';
import markdownlintParser from 'eslint-plugin-markdownlint/parser.js';
export default [
{
files: ['docs/adr/[0-9]*.md'],
plugins: { markdownlint },
languageOptions: { parser: markdownlintParser },
rules: {
'markdownlint/md041': 'error',
'markdownlint/md043': [
'error',
{ headings: ['?', '## Status', '## Context', '## Decision', '## Consequences'] },
],
},
},
];Apply these rules in .github/workflows/decision-records.yml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| markdownlint-cli2 (MD041, MD043) | markdownlint-cli2 | a record that opens without a top-level title, or whose headings are not one title followed by Status, Context, Decision and Consequences, in that order. docs/adr/.markdownlint-cli2.jsonc holds {"config":{"default":false,"MD041":true,"MD043":{"headings":["?","## Status","## Context","## Decision","## Consequences"]}}}. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| Name each record NNNN-title-with-dashes.md | GitHub Actions | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| Never reuse a number | GitHub Actions | two records that share a number, as when two branches each take the next one. |
| dangerfile.ts: new dependency | Danger JS | pull requests that add to dependencies in package.json with neither a new record in docs/adr/ nor a record number cited in the description. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
.github/workflows/decision-records.ymlconfiguration snippet
name: decision-records
on:
pull_request:
jobs:
check-records:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- name: Keep every record
run: |
DELETED=$(git diff --name-only --diff-filter=D origin/${{ github.base_ref }}...HEAD -- 'docs/adr/[0-9]*.md')
[ -z "$DELETED" ] || { echo "Restore and supersede: $DELETED"; exit 1; }
- name: Name each record NNNN-title-with-dashes.md
run: |
MISNAMED=$(find docs/adr -maxdepth 1 -name '*.md' | grep -vE '/([0-9]{4}-[a-z0-9-]+|README)\.md$' || true)
[ -z "$MISNAMED" ] || { echo "Rename to NNNN-title-with-dashes.md: $MISNAMED"; exit 1; }
- name: Never reuse a number
run: |
REUSED=$(ls docs/adr | grep -oE '^[0-9]{4}' | sort | uniq -d)
[ -z "$REUSED" ] || { echo "Renumber the later record: $REUSED"; exit 1; }
- name: Give every record its sections
uses: DavidAnson/markdownlint-cli2-action@v24
with:
globs: 'docs/adr/[0-9]*.md'Apply these rules in .pre-commit-config.yaml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| MD043 (required headings) | PyMarkdown | a record whose headings do not end with Status, Context, Decision and Consequences, in that order. PyMarkdown's MD043 has no ? wildcard for exactly one heading, so MD041 checks the title instead. The setting is headings: v0.9.41 ignores the required_headings name on the linked page and MD043 then passes every record. |
| MD041 (first line heading) | PyMarkdown | a record whose first line is not a top-level title. |
| pymarkdown hook | pre-commit | the gate. Runs both checks on every commit that stages a numbered record. The settings stay in the hook, so other Markdown keeps its own PyMarkdown rules. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| Name each record NNNN-title-with-dashes.md | GitHub Actions | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| Never reuse a number | GitHub Actions | two records that share a number, as when two branches each take the next one. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
.pre-commit-config.yamlconfiguration snippet
repos:
- repo: https://github.com/jackdewinter/pymarkdown
rev: v0.9.41
hooks:
- id: pymarkdown
files: ^docs/adr/[0-9]
args:
- --set=plugins.selectively_enable_rules=$!True
- --enable-rules=md041,md043
- '--set=plugins.md043.headings=*,## Status,## Context,## Decision,## Consequences'
- scanApply these rules in src/test/java/com/example/archtests/DecisionRecordsTest.java. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| isNamedByNumberThenTitle | JUnit | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| hasEverySectionInOrder | JUnit | a record whose sections are not Status, Context, Decision and Consequences, in that order. |
| opensWithItsNumberedTitle | JUnit | a record whose first line is not # N. Title, with N the number in its file name. |
| numbersCountUpFromOne | JUnit | gaps where a record was deleted and two records that share a number, as when two branches each take the next one. |
| mvn verify | Maven Surefire | the gate. The test runs with the suite, so a malformed record fails the build. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
src/test/java/com/example/archtests/DecisionRecordsTest.javaconfiguration snippet
package com.example.archtests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class DecisionRecordsTest {
private static final Path RECORDS = Path.of("docs/adr");
private static final String NUMBER_THEN_TITLE = "\\d{4}-[a-z0-9-]+\\.md";
private static final List<String> SECTIONS =
List.of("## Status", "## Context", "## Decision", "## Consequences");
static List<Path> records() throws IOException {
try (Stream<Path> files = Files.list(RECORDS)) {
return files
.filter(file -> file.toString().endsWith(".md"))
.filter(file -> !file.endsWith("README.md"))
.sorted()
.toList();
}
}
private static int numberOf(Path record) {
return Integer.parseInt(record.getFileName().toString().split("-")[0]);
}
@ParameterizedTest
@MethodSource("records")
void isNamedByNumberThenTitle(Path record) {
assertTrue(record.getFileName().toString().matches(NUMBER_THEN_TITLE), record.toString());
}
@ParameterizedTest
@MethodSource("records")
void hasEverySectionInOrder(Path record) throws IOException {
List<String> headings =
Files.readAllLines(record).stream().filter(line -> line.startsWith("## ")).toList();
assertEquals(SECTIONS, headings, record.toString());
}
@ParameterizedTest
@MethodSource("records")
void opensWithItsNumberedTitle(Path record) throws IOException {
String title = "# " + numberOf(record) + ". ";
assertTrue(Files.readAllLines(record).get(0).startsWith(title), record.toString());
}
@Test
void numbersCountUpFromOne() throws IOException {
List<Integer> numbers = records().stream().map(DecisionRecordsTest::numberOf).toList();
assertEquals(IntStream.rangeClosed(1, numbers.size()).boxed().toList(), numbers);
}
}Apply these rules in tests/Architecture/DecisionRecordsTest.php. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| testIsNamedByNumberThenTitle | PHPUnit | records not named NNNN-title-with-dashes.md, the shape adr-tools writes. |
| testHasEverySectionInOrder | PHPUnit | a record whose sections are not Status, Context, Decision and Consequences, in that order. |
| testOpensWithItsNumberedTitle | PHPUnit | a record whose first line is not # N. Title, with N the number in its file name. |
| testNumbersCountUpFromOne | PHPUnit | gaps where a record was deleted and two records that share a number, as when two branches each take the next one. |
| CaptainHook.FileStaged.InDirectory | CaptainHook | a malformed record at commit time. The captainhook.json pre-commit hook runs the test whenever a commit stages a file in docs/adr/. |
| Keep every record | GitHub Actions | pull requests that delete a record from docs/adr/. A reversed decision keeps its record and gains a newer one that supersedes it. |
| CODEOWNERS: /docs/adr/ | GitHub code owners | records accepted without a review from the team that owns the code, once branch protection has “Require review from Code Owners” turned on. |
| Required status checks | GitHub required status checks | merges while a decision-record check is red. The check runs on every pull request with no paths: filter, since a required check that a filter skips stays Pending and blocks the merge. |
| REVIEW.md and .cursor/BUGBOT.md | Claude Code Review and Cursor Bugbot | an architectural change with no record, or a rewritten accepted record, on the AI first pass. The pack's optional review files hold P8's rule line. |
tests/Architecture/DecisionRecordsTest.phpconfiguration snippet
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class DecisionRecordsTest extends TestCase
{
private const SECTIONS = ['## Status', '## Context', '## Decision', '## Consequences'];
public static function records(): iterable
{
$paths = glob('docs/adr/*.md');
foreach (array_diff($paths === false ? [] : $paths, ['docs/adr/README.md']) as $record) {
yield basename($record) => [$record];
}
}
#[DataProvider('records')]
public function testIsNamedByNumberThenTitle(string $record): void
{
self::assertMatchesRegularExpression('/^\d{4}-[a-z0-9-]+\.md$/', basename($record));
}
#[DataProvider('records')]
public function testHasEverySectionInOrder(string $record): void
{
preg_match_all('/^## [^\r\n]*/m', self::textOf($record), $headings);
self::assertSame(self::SECTIONS, $headings[0]);
}
#[DataProvider('records')]
public function testOpensWithItsNumberedTitle(string $record): void
{
self::assertStringStartsWith('# ' . self::numberOf($record) . '. ', self::textOf($record));
}
public function testNumbersCountUpFromOne(): void
{
$records = array_column(iterator_to_array(self::records()), 0);
$numbers = array_map(self::numberOf(...), $records);
self::assertSame(array_keys(array_fill(1, count($numbers), true)), $numbers);
}
private static function numberOf(string $record): int
{
return (int) explode('-', basename($record))[0];
}
private static function textOf(string $record): string
{
$text = file_get_contents($record);
self::assertIsString($text);
return $text;
}
}AI rules
.cursor/rules/p8-decision-records.mdc--- alwaysApply: true --- ## Prickles P8: Architecture Decision Records Record each architecturally significant decision as the next numbered file in `docs/adr/`, in the same pull request as the change. Before changing code, read the accepted records that govern it. Never rewrite or delete an accepted record: supersede it. A decision is architecturally significant when it affects structure, non-functional characteristics, dependencies, interfaces or construction techniques, or is hard to reverse. Skip choices a refactor can undo. Use the repository's existing decision folder if it has one. Name each record `NNNN-title-with-dashes.md` and never reuse a number. Keep it to a page: a `# N. Title` line, a `Date:` line, then Status, Context, Decision and Consequences in that order. Context gives the forces, the options weighed and why each lost. Never list an option nobody weighed. A record stays Proposed until a person on the team accepts it, never the agent that drafted it. Accepted records bind and superseded records are history. When a task conflicts with an accepted record, stop and ask which should change. The new record names the one it supersedes. In the old record, change only the status, to `Superseded by N`.
Repo layout, CI, and ESLint wiring for these paths live on /implementation, not repeated on every tenet.
Counter-argument
The steelman is empirical. Buchgeher and colleagues searched GitHub at the end of 2020 and found 921 repositories with an ADR. Of those, 554 held five or fewer, which the authors read as “tried but not yet definitively adopted”.10 In 5,800 records from those repositories, de Santana and colleagues found about 63% were opened as accepted, “bypassing the deliberative context that ADRs are intended to capture”. Adoption was associated with “at most, modest observable effects at scale”.11 Grudin's first challenge for groupware developers suggests a cause: the person who does the extra work is often not the one who benefits.12 On that reading a record is paperwork, filed after the decision.
Counter-argument retort
De Santana and colleagues are right that a record opened as accepted shows no deliberation in its own history. A debate may still have happened. Pryce's adr-tools writes Accepted into every record it creates and a squash merge leaves main with only the final version. Keeling and Runde's team made it a rule to settle a decision at a whiteboard before recording it.7 So the 63% mixes records written after a debate with decisions that never had one. Under P8 the debate happens in the pull request, whose thread survives a squash merge.
Grudin is right about a record that nobody reviews. The reviewer reads a P8 record before the change merges. The writer gains too, since Fowler counts writing a record as a way for a group to think a decision through.8 Most of Keeling and Runde's team read the records once or twice, months apart, yet everyone found the records “useful or very useful”.13
The research for P8 found no field study comparing onboarding or maintenance in teams with records against teams without. Five of the studies here rest on one sample: the 921 public GitHub repositories Buchgeher and colleagues found at the end of 2020.10 No closed-source team is in that sample. Davidson dissents from the rule against editing: he revises accepted records and lets Git keep the history.4 An agent reading the folder sees only current text, so P8 keeps the replaced decision on file beside the one in force.
Two tenets already send readers to a record. F5 Self-Documenting Code puts the reason for an architectural choice in an ADR. TS1 Living Documentation keeps behaviour in tests and names the ADR as the place for an explanation. P8 sets the rules for that record. The team reviews it with the code, accepts it and supersedes it when the decision changes.
Notes
- [1]Michael Nygard: “Documenting Architecture Decisions” (Relevance blog, 15 November 2011, now on Cognitect’s site). The post puts the records under doc/arch/adr-NNN.md in the project repository, in Markdown or Textile, each one or two pages long and numbered without reuse.
- [2]LaToza, Venolia & DeLine: “Maintaining Mental Models: A Study of Developer Work Habits” (ICSE 2006). Surveys 104 Microsoft developers and counts “Somewhat agree” as agreement on a seven-point scale. Code someone else wrote was a serious problem for 56%, against 17% for a developer’s own older code.
- [3]Michael Keeling: “Love Unrequited: The Story of Architecture, Agile, and How Architecture Decision Records Brought Them Together” (IEEE Software 39(4), 2022). The column goes on: “Instead of inadvertently gatekeeping design, ADRs gave developers direct access and empowered them to own it.”
- [4]Duncan Davidson: “Coding Agents Love Decision Records” (O’Reilly Radar, 2 October 2026, first published on duncandavidson.com on 1 September 2026). Records in the repository spare an agent from trawling issues and chats. Davidson revises accepted records and is “happy to have Git carry that history”.
- [5]Parnas & Clements: “A Rational Design Process: How and Why to Fake It” (IEEE Transactions on Software Engineering SE-12(2), 1986). Its documentation differs from the ideal in one way: a record of the alternatives considered and rejected.
- [6]Tyree & Akerman: “Architecture Decisions: Demystifying Architecture” (IEEE Software 22(2), 2005). Two Capital One architects call architecture decisions “the missing link” and give a 14-field template for describing each one. Recording the rationale and the options keeps answered questions from coming back.
- [7]Keeling & Runde: “Share the Load: Distribute Design Authority with Architecture Decision Records” (Agile Alliance experience report, Agile2018). An IBM Watson team recorded over 80 decisions in two years, reviewed through GitHub pull requests. After early reviews churned, the team adopted a rule to record a decision only once it had been made.
- [8]Martin Fowler: “Architecture Decision Record” (martinfowler.com bliki, 24 March 2026). Superseding an accepted record, never editing it, leaves “a clear log of decisions and how long they governed the work”. Writing a record helps a group clarify its thinking.
- [9]GitHub Well-Architected: “Shared knowledge for agentic engineering” (GitHub Well-Architected, 20 May 2026). Architecture decision records “sit in the repository where agents can reference them, and are pruned or clearly superseded when the decision no longer holds”. The page gives no evidence of effect.
- [10]Buchgeher et al.: “Using Architecture Decision Records in Open Source Projects—An MSR Study on GitHub” (IEEE Access 11, 2023). The 921 repositories are a lower bound. The later quantitative studies by de Santana, Miccio, Su and Gupta draw on the same repositories.
- [11]de Santana et al.: “Architecture Decision Records: Adoption, Impact, and Developer Engagement in Open-Source Software” (ICSA 2026). The 921 repositories, “from earlier ADR studies”, match Buchgeher’s count. ADR variables showed “predominantly small correlations” with code quality and productivity metrics. Read from the abstract, as the full text is paywalled.
- [12]Jonathan Grudin: “Groupware and Social Dynamics: Eight Challenges for Developers” (Communications of the ACM 37(1), 1994). The first challenge is “the disparity between who does the work and who gets the benefit”. Grudin wrote about groupware, so the point is used here as an analogy.
- [13]Keeling & Runde: “Architecture Decision Records in Action” (SEI SATURN 2017). About nine engineers wrote 40 records between April 2016 and May 2017. “Everyone found them useful or very useful”, though most teammates consulted the records only “once or twice, months between reviews”. The slides report one team’s view.