Architecture decisions agents can follow
An architecture decision an agent can follow is a one-page architecture decision record (ADR) in the repository whose constraints are MUST or MUST NOT rules, each naming the check that enforces it. The agent reads the ADR before editing the affected code, CI fails when a constraint breaks, and only a named human accepts or supersedes the decision.
Your team decided in August that all outbound HTTP goes through one wrapper with timeouts and retries. The decision lives in a wiki page. In September an agent adds axios to fix a flaky integration, a second agent calls fetch straight from the domain layer, and both pull requests pass their tests. Nobody broke a rule the agent could see, so no review caught it. This page is for the tech lead who owns those decisions and the developers whose agents have to respect them.
What you get from ADRs that agents follow
Section titled “What you get from ADRs that agents follow”- An ADR template with a
Constraintssection, where every rule names its check or is marked for human review. - A decision table for which choices deserve an ADR, which need only a lint rule, and which need neither.
- Tested constraint rules for TypeScript, Python and Java whose failure message cites the ADR, so the agent reads the reason, not only the rule.
adr-lint, a 43-line script that fails CI when an accepted ADR names a check that does not exist, or a rule cites an ADR that was superseded.- Per-tool setup that puts the right ADR in front of Claude Code, Codex and Cursor, plus a stop-and-propose rule for decisions no ADR covers.
- Four copy-paste prompts for drafting, plan-checking, recovering and superseding decisions.
Why do agents erode architecture decisions?
Section titled “Why do agents erode architecture decisions?”Three gaps let a decision slip, and a better prompt closes none of them.
- The agent never sees the decision. A wiki page, a meeting note or a senior engineer’s memory is outside the agent’s context. The agent optimizes for the task in front of it, and the shortest path often crosses a boundary nobody wrote down.
- The decision is prose the agent can reinterpret. “Prefer the shared HTTP wrapper” gives way under pressure: the agent decides that this one case is an exception.
- Nothing fails when the decision breaks. Tests check behavior, and a pull request that adds a second HTTP client behaves correctly.
DORA links architecture to whether AI helps at all: Google Cloud’s announcement of the 2025 DORA report (23 September 2025) states that “Teams working in loosely coupled architectures with fast feedback loops see gains, while those constrained by tightly coupled systems and slow processes see little or no benefit.” Our inference: a decision that erodes silently pushes a codebase toward the tightly coupled side.
An ADR closes the first gap when it sits in the repository and is loaded into the agent’s context. Constraints written as MUST or MUST NOT close the second. A check that CI runs closes the third, and architecture fitness functions is the page that turns those checks into required gates.
Which decisions need an ADR, and which need only a rule?
Section titled “Which decisions need an ADR, and which need only a rule?”An ADR costs a human decision and a review. Spend that on choices that are expensive to undo. Matt Pocock’s domain-modeling skill (in mattpocock/skills, checked 26 September 2026) offers an ADR only when three things are true: the choice is “Hard to reverse”, “Surprising without context”, and “The result of a real trade-off”. Use the same test, then decide how each constraint is enforced.
| The choice | ADR? | How it is enforced |
|---|---|---|
| Module boundaries and dependency direction (“the domain does no I/O”) | Yes | Dependency rule: dependency-cruiser, import-linter or ArchUnit |
| One library per concern (HTTP client, ORM, date handling, logging) | Yes | Banned-import rule: ESLint no-restricted-imports, Ruff TID251, ArchUnit |
| A new data store, queue or external service | Yes | Human review: code owners on infrastructure code and dependency manifests |
| A public API or event contract | Yes | API-surface or contract diff in CI (see the fitness functions page) |
| Error-handling, retry or idempotency patterns | Yes, if a real trade-off was made | Lint rule where possible, otherwise a Review: constraint the reviewer checks |
| Naming, formatting, file layout | No | Linter and formatter configuration, with no ADR |
| “Use boring technology”, “keep it simple” | No | Nothing can check it, so it does not constrain an agent. Put the reasoning in the Context section of the ADR it motivates |
The last row matters. A principle with no check does not stop an agent. Either turn it into a specific constraint (“MUST NOT add a runtime dependency without an accepted ADR”) or leave it in the Context section as the reason behind one.
What does an ADR that agents can follow look like?
Section titled “What does an ADR that agents can follow look like?”Start from the widely used MADR template (4.0.0, 17 September 2024). MADR already has an optional “Confirmation” element, which asks how compliance with the ADR “can/will be confirmed” and suggests “a design/code review or a test with a library such as ArchUnit”. The agent-ready version makes that section mandatory and machine-readable, and adds the instruction the agent needs when the decision does not fit the task.
---status: accepteddate: 2026-09-14decision-makers: Anna Kowalska (tech lead), Tomasz Nowak (platform)scope: src/**---
# ADR-0012: All outbound HTTP goes through src/lib/http.ts
## Context
Three HTTP clients are in use (axios, got, the global fetch), each with its owntimeout and retry behavior. During the 2026-08-30 payment-provider outage, callsthrough got hung for 120 seconds because it had no timeout, and the checkoutworkers exhausted their pool.
## Decision
All outbound HTTP calls go through `src/lib/http.ts`, a wrapper over the platform`fetch` with a 10-second default timeout, retries with jitter for idempotentmethods, and trace headers. We add no other HTTP client library.
## Consequences
- Good: one place to change timeouts, retries and tracing; one behavior during an outage.- Bad: streaming uploads and websockets need the wrapper extended first.- Given up: axios interceptors, which two services used for auth headers.
## Constraints
- **adr-0012-c1** Code under `src/` MUST NOT import `axios`, `got`, `node-fetch` or `undici`. Check: `eslint.config.js#adr-0012-c1`- **adr-0012-c2** Code under `src/domain/` MUST NOT import `src/lib/http.ts`; the domain does no I/O. Check: `.dependency-cruiser.cjs#adr-0012-c2`- **adr-0012-c3** New call sites pass an explicit timeout when a call can take longer than 10 seconds. Review: the reviewer confirms it from the test that covers the slow path.
## When the agent must stop
If a task needs something the wrapper cannot do (streaming, websockets, a clientcertificate), stop. Write a proposed ADR that amends this one, and do not add aclient library.
## Revisit when
More than one service needs websockets, or the platform `fetch` gains a feature wewould otherwise wrap.Four rules make this format work for agents:
- One page, one decision. The agent loads it into context on every relevant task, so every extra paragraph costs tokens on every run. Keep the options analysis in the pull request.
- Every constraint has an ID and a verdict source.
Check:names a file and the rule ID inside it;Review:names who checks by hand and from what evidence. A constraint with neither is a wish. - The rule’s own message cites the ID. When the check fails, the agent sees
adr-0012-c1and the ADR path, opens the ADR and reads why. scopesays where the decision applies. The per-tool routing uses it to load only the ADRs that touch the files being edited.
How do you write a constraint a tool can check?
Section titled “How do you write a constraint a tool can check?”Most architecture decisions reduce to two rule shapes: “this code must not import that code” and “nobody imports this library”. Every mainstream stack has a tool for both. The rules below were run on 26 September 2026 (ESLint 10.11.0 with typescript-eslint 8.70.1, dependency-cruiser 18.4.0, Ruff 0.16.9), and each one failed on a planted violation with the ADR ID in its output.
The banned-library constraint is ESLint’s built-in no-restricted-imports rule. The message is what the agent reads:
import { defineConfig } from 'eslint/config';import tseslint from 'typescript-eslint';
export default defineConfig({ files: ['src/**/*.ts'], languageOptions: { parser: tseslint.parser }, rules: { 'no-restricted-imports': ['error', { paths: ['axios', 'got', 'node-fetch', 'undici'].map((name) => ({ name, message: 'adr-0012-c1: outbound HTTP goes through src/lib/http.ts. See docs/adr/0012-outbound-http.md.', })), }], },});The boundary constraint is a dependency-cruiser rule named after the constraint ID:
// .dependency-cruiser.cjs (add to the forbidden array){ name: 'adr-0012-c2', severity: 'error', comment: 'ADR-0012: the domain layer does no I/O. Pass the data in from src/app instead of importing src/lib/http.ts.', from: { path: '^src/domain/' }, to: { path: '^src/lib/http\\.ts$' },},A violation prints 'axios' import is restricted from being used. adr-0012-c1: outbound HTTP goes through src/lib/http.ts… from ESLint and error adr-0012-c2: src/domain/order.ts → src/lib/http.ts from dependency-cruiser. Run ESLint with --no-inline-config so an eslint-disable comment cannot switch the constraint off.
Ruff’s TID251 (banned API) carries the message the agent reads. In pyproject.toml:
[tool.ruff.lint]extend-select = ["TID251"]
[tool.ruff.lint.flake8-tidy-imports.banned-api]"requests".msg = "adr-0012-c1: outbound HTTP goes through shop.http. See docs/adr/0012-outbound-http.md""httpx".msg = "adr-0012-c1: outbound HTTP goes through shop.http. See docs/adr/0012-outbound-http.md"ruff check src then reports TID251 `requests` is banned: adr-0012-c1: … for every import of a banned module. Boundary constraints go in import-linter forbidden or layers contracts. Name the contract after the constraint (name = "adr-0012-c2 domain does no I/O"), so the ID appears in the failure. The full import-linter setup is on the fitness functions page.
ArchUnit rules take a because() clause, which ArchUnit prints with the violation:
@ArchTeststatic final ArchRule adr0012c1 = noClasses() .should().dependOnClassesThat().resideInAnyPackage("okhttp3..", "org.apache.hc..") .because("adr-0012-c1: outbound HTTP goes through com.acme.shop.http.HttpGateway. See docs/adr/0012-outbound-http.md");
@ArchTeststatic final ArchRule adr0012c2 = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..http..") .because("adr-0012-c2: the domain does no I/O");Keep the rule ID in the because text, not only in the field name, so it shows in the Maven output the agent reads. The ArchUnit setup, including the surefire version that actually runs these tests, is on the fitness functions page.
A decision about which data store or queue to use rarely reduces to an import rule. Mark it Review:, and put the manifest files (package.json, pyproject.toml, pom.xml, infrastructure code) under code owners, so a new dependency cannot merge without a human who knows the ADRs. To check that a dependency the agent adds is real and safe, see dependency verification.
Set up agent-ready ADRs, step by step
Section titled “Set up agent-ready ADRs, step by step”-
Create
docs/adr/and an index.docs/adr/README.mdholds one table row per ADR: ID, status, scope and the rule in one line. The ID cell links to the file, which is whatadr-lintlooks for:| [ADR-0012](0012-outbound-http.md) | accepted | src/** | Outbound HTTP only through src/lib/http.ts |. The index is what the agent loads on every task; the full ADR loads only when the agent edits files in its scope. -
Record the decisions the team has already made. Run the “recover implicit decisions” prompt below. It finds the patterns the codebase already follows and counts the exceptions. The tech lead picks the three to five that matter and the team accepts them as ADRs. The agent drafts; it does not decide.
-
Land each ADR with its checks in one pull request. The ADR, the lint or dependency rule and, for a brownfield codebase, a baseline of existing violations go in together. A decision without its check is not accepted.
-
Add
adr-lintto CI. The script in How do you prove the decisions still hold? fails the build when an ADR and its checks drift apart. -
Route the ADRs into each agent’s context. Use the per-tool setup below. The shared part is one instruction block in
AGENTS.mdorCLAUDE.md. -
Put ADRs and rule files under code owners. Add
/docs/adr/, the rule configuration files andscripts/adr-lint.mjstoCODEOWNERSfor the tech lead, and require code-owner review in the branch ruleset. Without that setting,CODEOWNERSonly requests a review; it does not block the merge. -
Teach the agent to stop and propose. When a task needs a choice no ADR covers (a new dependency, a new store, a boundary crossing), the agent writes
docs/adr/NNNN-title.mdwithstatus: proposedand stops. A human accepts, rejects or amends it. -
Supersede; never edit. When a decision changes, a new ADR replaces the old one, the old status becomes
superseded by ADR-NNNN, and the rules that cited the old ID are renamed or deleted in the same pull request. CI stays red on that pull request until the tech lead setsstatus: acceptedon the new ADR in the same PR, and that red state is the approval gate.
How do you put ADRs in front of the agent?
Section titled “How do you put ADRs in front of the agent?”The instruction block is the same in all three tools. Put it in AGENTS.md, or in CLAUDE.md if your team uses Claude Code only:
## Architecture decisions- The accepted decisions are indexed in docs/adr/README.md. Before you edit a file, read every accepted ADR whose scope matches it.- A lint or dependency failure that cites `adr-NNNN-cN` is an architecture decision, not a style nit. Fix the code; never edit the rule, its baseline or the ADR.- If the task needs a new runtime dependency, a new data store or queue, a new public API, or a change an ADR forbids: stop. Write docs/adr/NNNN-short-title.md with `status: proposed`, the options you considered and the constraint you propose, then report back without implementing.- Never set an ADR's status to accepted. A human does that in review.The tools differ in how reliably the right ADR reaches the context.
Import the index into CLAUDE.md so it loads at launch. Claude Code expands @path imports into context when the session starts, and a path inside backticks stays literal text instead:
@docs/adr/README.mdThen give each high-traffic ADR a path-scoped rule. A file in .claude/rules/ with a paths field loads only when Claude reads a matching file, so the full constraint text arrives exactly when it is needed:
---paths: - "src/**/*.ts"---
# ADR-0012 applies hereOutbound HTTP only through src/lib/http.ts (adr-0012-c1). Nothing under src/domain/imports it (adr-0012-c2). Full decision: docs/adr/0012-outbound-http.mdSave it as .claude/rules/adr-0012-outbound-http.md. Both mechanisms are documented in Claude Code’s memory reference, checked against Claude Code 2.1.283 on 26 September 2026. If you keep one AGENTS.md for every tool, Claude Code reads it directly when the project has no CLAUDE.md from v2.1.277 (the latest channel on 26 September 2026); on older versions, put @AGENTS.md in a CLAUDE.md.
To check a plan against the ADRs before any file changes, start the session with claude --permission-mode plan and use the plan-check prompt below. To stop Claude editing the rule files, use the deny rules and Stop hook from the fitness functions page.
Put the instruction block in the root AGENTS.md. OpenAI’s documentation says “Codex reads AGENTS.md files before doing any work” (checked 28 August 2026). Since Codex CLI 0.150.0, untrusted projects no longer supply project-level AGENTS.md instructions, so trust the repository, or the ADR rules silently do not load. CI still enforces the checks either way.
For the plan check, type /plan in a Codex session in the repository. For a headless check of a finished branch, run Codex read-only:
codex exec -c default_permissions=":read-only" "Read docs/adr/README.md and every accepted ADR whose scope matches a file changed in git diff origin/main...HEAD. For each constraint marked Review:, report PASS or FAIL with the file and line as evidence. Do not edit files.":read-only is a built-in permission profile (beta in Codex CLI 0.157.1); the legacy --sandbox read-only flag does the same job.
Codex code review on GitHub also reads review rules from AGENTS.md (checked 28 August 2026), so add: “Flag any change that violates a Review: constraint of an accepted ADR, citing the constraint ID.”
Add the instruction block as a project Rule that applies to every Agent request, and point it at docs/adr/README.md. Rules “provide system-level instructions to Agent” (cursor.com/docs/rules, checked 28 August 2026). We could not re-check rule file formats or scoping options on 26 September 2026, because cursor.com was unreachable from our environment. Take the current format from Cursor’s rules documentation on the day you set it up.
Use Plan Mode, which “creates detailed implementation plans before writing any code”, with the plan-check prompt below before a large change. Cloud Agents run on their own machines, so nothing on your laptop binds them; only the required CI checks are guaranteed to. Bugbot can review pull requests against the Review: constraints, but it is a reviewer, not a gate.
Matt Pocock’s grill-with-docs skill makes the drafting part conversational: it interviews you about a design and writes agreed decisions to docs/adr/ as you go. It had 1,046,834 installs on skills.sh in a 26 September 2026 snapshot (a secondary count, from the LinklyAI/best-skills mirror). Install it with claude plugin install mattpocock-skills in Claude Code (the whole 25-skill plugin, about 1,600 tokens of skill descriptions per session; check with claude plugin details mattpocock-skills), or npx skills add mattpocock/skills --skill grill-with-docs grilling domain-modeling -a codex for Codex (-a cursor for Cursor). Install all three skill names: grill-with-docs is a one-line pointer to the other two. Its ADRs have no Constraints section, so add one before you accept them. More skills of this kind are compared in the top development-practice skills.
Copy-paste prompts for architecture decisions
Section titled “Copy-paste prompts for architecture decisions”Run the plan check in plan mode. The first and third prompts write only the files they name, and nothing they produce is accepted until a human says so; if the first prompt’s new rules land in CI, they stay red until the ADR is accepted. The fourth edits rule files, so a human reviews its pull request as a rule change, through code owners.
How do you prove the decisions still hold?
Section titled “How do you prove the decisions still hold?”Three checks confirm an ADR still holds, without anyone re-reading the codebase: the rules themselves, a traceability check between ADRs and rules, and a record of who accepted what.
The rules run as required CI checks on every pull request, as set up on the fitness functions page, including the canary that proves a rule can still fail.
The traceability check is scripts/adr-lint.mjs. It fails when an accepted ADR has no constraints, when a constraint has neither Check: nor Review:, when a check names a rule ID that no longer exists, when a rule cites an ADR that is not accepted, or when an ADR is missing from the index:
#!/usr/bin/env node// scripts/adr-lint.mjs: every accepted ADR constraint names a live check,// and every check names an accepted ADR.import { existsSync, readdirSync, readFileSync } from 'node:fs';
const DIR = 'docs/adr';const ENFORCERS = ['eslint.config.js', '.dependency-cruiser.cjs']; // add pyproject.toml, ArchitectureTest.java…const errors = [];const accepted = new Set();const index = readFileSync(`${DIR}/README.md`, 'utf8');
for (const file of readdirSync(DIR).filter((f) => /^\d{4}-.*\.md$/.test(f))) { const text = readFileSync(`${DIR}/${file}`, 'utf8'); const id = file.slice(0, 4); const status = text.match(/^status:\s*(.+)$/m)?.[1].trim() ?? 'missing'; if (!index.includes(`(${file})`)) errors.push(`${file}: not listed in ${DIR}/README.md`); if (status !== 'accepted') continue; accepted.add(id); const section = text.split(/^## Constraints$/m)[1]?.split(/^## /m)[0] ?? ''; const bullets = section.split('\n').filter((l) => l.startsWith('- ')); if (bullets.length === 0) errors.push(`${file}: accepted ADR has no constraints`); for (const line of bullets) { const check = line.match(/Check: `([^`#]+)#([^`]+)`/); if (check) { const [, path, ruleId] = check; if (!existsSync(path) || !new RegExp(`\\b${ruleId}\\b`).test(readFileSync(path, 'utf8'))) errors.push(`${file}: ${ruleId} is not defined in ${path}`); } else if (!line.includes('Review:')) { errors.push(`${file}: constraint has neither "Check:" nor "Review:": ${line.slice(0, 60)}`); } }}
for (const path of ENFORCERS.filter((p) => existsSync(p))) { for (const [, id] of readFileSync(path, 'utf8').matchAll(/adr-(\d{4})-c\d+/g)) if (!accepted.has(id)) errors.push(`${path}: rule cites ADR-${id}, which is not accepted`);}
if (errors.length) { console.error(errors.join('\n')); process.exit(1);}console.log(`adr-lint: ${accepted.size} accepted ADRs, every constraint traced.`);We ran it on 26 September 2026 with Node.js against the ADR-0012 example above. It passed on the clean setup and failed in each case we planted: a renamed rule (adr-0012-c2 is not defined in .dependency-cruiser.cjs), a superseded ADR whose rules were still live (rule cites ADR-0012, which is not accepted), and a constraint with no verdict source. Add it as one more step in the fitness CI job, node scripts/adr-lint.mjs. It needs no dependencies and no API key.
Sign-off follows the same split as the fitness gates:
| Who | Owns | Approves |
|---|---|---|
| Tech lead | The ADR set, its index and the rule files | Every ADR status change to accepted or superseded, through code owners |
| Team | The decision itself | The ADR pull request, in an ordinary review, before it is accepted |
| Developer or agent | Keeping the checks green; drafting proposed ADRs | Nothing in accepted ADRs or rules |
| Reviewer (human or review agent) | The Review: constraints | Each pull request that touches an ADR’s scope |
Three numbers tell the tech lead whether the system is working, each read from the repository: proposed ADRs per month (the agents are stopping at decisions instead of making them), constraint failures caught per week on agent pull requests (the rules are live), and accepted constraints marked Review: (a falling share means more of the architecture is machine-checked). They belong in the evidence bundle the team reads instead of the diff.
What breaks when agents follow ADRs?
Section titled “What breaks when agents follow ADRs?”The agent edits the ADR to match its code. It changes a constraint, or sets its own proposal to accepted. Recovery: code owners on docs/adr/, the “never set accepted” line in the instruction block, and a review that treats any diff to an accepted ADR as a rule change. adr-lint catches a rule and an ADR that disagree after such an edit.
The agent satisfies the letter and breaks the intent. It wraps axios in a file called src/lib/http2.ts, or moves I/O into a folder the rule does not cover. Recovery: write constraints against what is allowed (“only src/lib/http.ts may import undici”) rather than a list of what is banned. In dependency-cruiser that is a forbidden rule with from: { pathNot: '^src/lib/http\\.ts$' } and to: { path: 'node_modules/(undici|axios|got)' }, so a new http2.ts fails as soon as it imports a client. Then add the loophole to the rule the day you find it. The reviewer’s triage in reviewing an agent’s pull request treats new files next to a boundary as a risk flag.
The ADR folder outgrows the context. Forty ADRs loaded on every task crowd out the code. Recovery: load only the index at launch, keep each index row to one line, and load full ADRs through path-scoped rules or the “read the ADR whose scope matches” instruction. Supersede aggressively: a superseded ADR drops out of the index’s accepted rows.
A stale ADR makes the agent “fix” correct code. The code moved on with the team’s agreement, but nobody superseded the ADR, and the agent reverts the change to comply. Recovery: a decision changes only through a new ADR, in the same pull request as the code. The Revisit when line gives the tech lead a trigger to review each ADR, and the recover-decisions prompt shows where code and ADRs disagree.
Every agent session stops to propose an ADR. The stop list is too broad, and the team drowns in proposals. Recovery: narrow the stop list to the four triggers in the instruction block, and write ADRs for the patterns the agent keeps asking about. If the same question comes up three times, the team has an unwritten decision.
The rule exists but nobody remembers why. An old lint rule blocks a good change and the agent cannot argue with it. Recovery: adr-lint rejects any rule that cites an ADR that is not accepted, so every adr- rule has a reason on record. For disputes, the agent writes its case to the same ARCH_DISPUTE.md the fitness functions page uses, and the tech lead decides.
Where to go next with architecture decisions
Section titled “Where to go next with architecture decisions”On the tech-lead track, the next step is enforcing these constraints as CI gates.