- Published on
An Unenforceable ADR Is a Wish: Building adrkit
- Authors

- Name
- Mark Beacom
- @mbeacom
Your organization decides something. Six months later nobody remembers, the decision gets re-litigated in a thread, and the code has quietly drifted from what was agreed. That was already a bad enough problem when humans were the only ones proposing changes.
Now agents write plans too — faster than anyone can review them, with no memory of what was already decided and, more importantly, no memory of what was already rejected.
I've been building adrkit to attack that specific failure. It's open source under Apache-2.0, on GitHub, and published to npm. This post is about the argument behind it as much as the tool: what architecture decision records are actually for, why architecture review boards fail in two opposite directions, and what changes when you stop treating a decision record as a document and start treating it as data.
Written against v0.3.0 and kept current through v0.11.0. Since publication the project shipped a Spec Kit extension, inbound @adr source markers, a portable agent plugin, terminal-aware CLI output, and a container image, and it took its first outside contributions. Version-specific claims below reflect v0.11.0; the argument is unchanged. Two follow-ups go deeper: what running it on real projects actually broke, and why the CLI now behaves differently in a terminal than in a pipe.
- The failure mode isn't writing decisions. It's everything after.
- The one field that changes everything
- Where architecture review boards actually break
- Agents need the graveyard, not the highlight reel
- Deterministic before probabilistic
- What's actually shipped
- Dogfooding, and why it's load-bearing here
- Why this matters more now than it did two years ago
The failure mode isn't writing decisions. It's everything after.
The ADR practice itself is sound and well-established. MADR, Nygard-style records, the whole lineage: the idea that you should write down why you chose something, in the repo, next to the code, is one of the highest-leverage documentation habits a team can build.
The tooling around it is where it stalls. Most ADR tooling is a markdown template plus a static site generator. That records a decision. It doesn't make the decision do anything. Six months later the record is still sitting there, perfectly formatted, entirely inert, while the thing it governs gets rewritten by someone who never knew it existed.
I've made a version of this argument before about architectural diagrams, and the parallel is exact. Diagrams answer what the system is. ADRs answer why it is that way. Both are strategic infrastructure, and both rot the same way. They become prose that nobody can enforce, drifting from reality at exactly the speed the system evolves.
A template standardizes how you write a decision. It does nothing about what happens after the record exists. Those are different problems, and the second one is the expensive one.
The one field that changes everything
adrkit's thesis fits in a sentence: treat a decision record as typed data with a markdown body, and give it one field declaring what it governs.
---
id: '0042'
title: Use server-side rendering for authenticated routes
status: accepted
reversibility: one-way-door
blastRadius: cross-team
affects:
- type: path
pattern: 'apps/web/app/(authed)/**'
- type: package
pattern: 'next@>=16'
---
That affects field is the whole trick. Once a decision declares its own scope in a machine-readable way, a tool can answer the question that actually matters: which decisions govern this pull request? And then it can put the answer where the next decision is being made, instead of in a wiki nobody opens.
Matchers are typed: path, package, entity, resource, api, data, with optional negate for carve-outs. Resolution is a pure function: same corpus, same file set, same answer, every time. That property is non-negotiable, because a matcher that's only mostly reproducible can't be a CI gate.
The frontmatter is a strict MADR superset, deliberately. This isn't "instead of MADR" — adr migrate --from madr adopts an existing corpus in place, additively, reading MADR 3.x frontmatter, MADR 2.x * Status: bullets, and Nygard ## Status sections. Your current tooling keeps working.
From the CLI:
npx @adrkit/cli lint # validate the corpus in docs/adr
npx @adrkit/cli explain src/payments/api.ts # which decisions govern this file?
adr explain prints every decision governing a file and the matcher that fired — that second part matters, because "trust me, this applies" is how governance tools lose credibility. Only accepted records are reported as governing; matched proposals and superseded/rejected records are listed separately rather than silently dropped.
Since v0.4.0 that resolution runs in both directions. A record declares the paths it governs with affects; a source file can declare the record it lives under with an @adr 0012 marker on a dedicated comment line. The output keeps the two apart: via path: src/sync/** versus declared by src/sync/retry.ts:1, which lets affects stay narrow, covering the files that define a decision, while the surrounding code opts in one line at a time in any language, with no schema change. That capability came from outside the project: Antoine Balliet built inbound markers and then wired them into check (#97, #106). The fence-tracking bug it arrived with meant this project's own documentation of the marker syntax was being read as a use of it, which David Sheffer fixed by reading a marker only where the format actually hides the line, and followed with the measured-scan-extent report (#109, #120).
In CI, the @adrkit/ci Action comments the governing decisions on the PRs that touch them. It runs with only the default GITHUB_TOKEN, and it's read-only and comment-only by design. No database, no approval authority, and it degrades to a log notice rather than failing the job on a read-only fork token.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: mbeacom/adrkit/packages/ci@v0
Where architecture review boards actually break
Every organization that has tried to run an ARB has watched it fail in one of two directions, and usually both at once in different corners of the org.
The bottleneck
Everything escalates. Every decision needs the board. The board meets biweekly, the queue is thirty items deep, and the median proposal waits a month for a verdict that takes four minutes to render. Teams respond rationally: they route around it. Decisions get made anyway, just without the record — which is strictly worse than having no board, because now you have the illusion of governance plus the delay.
The rubber stamp
The opposite failure. Everything gets waved through because reviewing everything properly is impossible, so review becomes ceremonial. You've manufactured false assurance with a compliance story attached. When something goes wrong, the audit trail says "reviewed and approved" and means nothing.
The root cause of both is the same: treating every decision as equally risky. It never was. Renaming an internal module and choosing a data residency model are not the same class of decision, and any process that routes them identically is either too slow for one or too fast for the other.
That's why adrkit's schema carries reversibility (two-way-door / one-way-door / unknown) and blastRadius (component / team / cross-team / org) as first-class typed fields rather than prose. They make the risk shape of a decision explicit and checkable instead of implied.
Routing itself is a separate, declared field, review.tier, paired with a tierReason, and the labels are fixed:
| Tier | Label |
|---|---|
auto | expedited routing; human acceptance required |
async | asynchronous human review |
arb | ARB human review |
Two design choices there are worth pulling out, because they're the difference between routing and rubber-stamping.
The tool never fabricates a tier. If review.tier is absent, the queue emits a finding saying routing can't be determined — it does not quietly assume a default. Untriaged is a visible state, not an empty cell.
auto is not auto-approve. Its label says so in every output, by requirement. The fast path is expedited routing; a human still accepts. And the schema enforces the obvious interlock as a cross-field invariant: a record with reversibility: one-way-door and review.tier: auto fails validation outright. You can't declare something irreversible and then wave it through.
adr queue emits the resulting review backlog as a deterministic, read-only projection of the corpus (tiers, SLA state, approvals, objections) as Markdown or JSON, byte-for-byte identical for identical inputs:
# ARB Queue — 2026-07-25
Corpus fingerprint: `96e7f3185c5bb89bd1c87e10a28dcbef66703f381d3f14ea486ceaf29903cb00`
7 item(s) | 0 corpus finding(s) | 0 item(s) with findings
## Queue Items
| # | ID | Title | Tier | SLA State | Deadline | Approvals | Objections |
|---|----|-------|------|-----------|----------|-----------|------------|
| 1 | `0005` | Gate proposals with a deterministic-first evaluator … | arb | within-sla | 2027-01-18 | 0/- | 0 |
| 2 | `0015` | Validate descriptors against Backstage field formats … | arb | within-sla | 2027-01-25 | 0/- | 0 |
There's a companion Action (mbeacom/adrkit/packages/ci/queue@v0, needs issues: write) that maintains that report as a single managed GitHub issue, so the queue lives where the work already is.
Escalation always routes to a named human, resolved from deciders, then CODEOWNERS of the affected paths, then the catalog owner. "Escalated to the ARB," with no name attached, is how proposals die quietly.
Agents need the graveyard, not the highlight reel
This is the part I think is most differentiated, and it's the piece that only became urgent in the last couple of years.
When an agent proposes an architectural change, the expensive failure isn't a syntax error. It's re-proposing something your organization already evaluated and rejected, for reasons that were good then and are still good now. The agent has no way to know. The rejection lives in a closed PR thread, a Slack channel that rolled off retention, or an ADR marked superseded that no retrieval path ever surfaces.
So @adrkit/mcp is a local, read-only Model Context Protocol server that lets an agent retrieve prior decisions, explicitly including the rejected and superseded ones, before proposing something already tried. Exactly four tools:
| Tool | Purpose |
|---|---|
search_decisions | Filtered search across the corpus |
get_decision | Fetch one record by id |
get_decision_context(files[]) | Decisions governing a set of files |
list_superseded | The graveyard: what was already rejected |
npx @adrkit/mcp
adrkit-mcp --cwd /path/to/repo --dir docs/adr
The constraints are the interesting part. No writes. No HTTP, no auth surface. No model, no embeddings, no network access, no persistent index. --cwd must be a Git worktree root. stdout carries only JSON-RPC frames; diagnostics go to stderr. The graveyard is included by default — you have to opt out of seeing what failed.
Read-only is a deliberate architectural commitment, not a v1 limitation. Git stays the source of truth, and every machine-authored change to the corpus goes through a pull request like anything else. An agent that could silently rewrite the decision log would defeat the entire purpose of having one.
Deterministic before probabilistic
The last piece is proposal evaluation, and it's where I've been most careful, because the obvious implementation is also the worst one.
The obvious implementation is: send the proposal to a model, ask "is this good?", print the score. That gets you evaluator theater: a model that rates everything "looks good," reviewers who stop reading it within weeks, and a rubber stamp with a compliance story attached. It's unexplainable to an auditor ("the model decided" is not an answer) and uncalibratable in practice.
So @adrkit/evaluator inverts the order. Anything checkable without a model is checked without a model, first. Pass 0 is fully deterministic: eleven rules covering schema validity, id collisions, supersession reciprocity and cycles, orphaned references, affects resolvability and overlap with accepted records, scope-hierarchy contradictions, assertion compilation, decider resolution, and expiry sanity. Errors short-circuit before a single token is spent.
adr evaluate <proposal.md> --snapshot <bundle.json> --date YYYY-MM-DD
The signature is unusual on purpose: the evaluator reads no model, no network, and no clock. The date and the corpus snapshot are inputs, which is what makes a run reproducible months later during a postmortem or an audit.
Two rules govern the design:
Escalation is a boolean OR over declarative conditions — never model discretion. One-way doors, non-empty complianceControls, security-sensitive paths, data-residency boundaries, contradicting an accepted ADR, agent-authored changes reaching production infra, and human-requested, which is always available and never overridden. Every escalation carries a reason code you can point at.
The evaluator never approves. It routes. A non-escalated proposal isn't approved. It's eligible for its routed tier. The tool moves proposals through a queue; it does not empty it. Humans decide.
Pass 0 (deterministic, model-free) is implemented and shipped. The later probabilistic passes (retrieval, eight-dimension rubric scoring, and a separate adversarial pass) are specified in the rubric but not built yet. The record that specified all four, ADR-0005, sat at proposed for three weeks and was then superseded by ADR-0027, which ratifies the deterministic pass on its own and moves the calibration reporting the original promised onto whenever Pass 1 ships. If you read the rubric, read it as a design document.
That callout is the whole ethos, so I'd rather state it plainly than let a launch post drift looser than the project's own README.
What's actually shipped
v0.11.0 is on npm. Four packages release in lockstep, all Node 22+ targeted:
@adrkit/core. Parsing, validation, migration, andaffectsresolution. Pure library, no adapters.@adrkit/cli. Theadrbinary:lint,new,graph,explain,check,migrate,evaluate,queue.@adrkit/evaluator. Deterministic Pass 0 and routing.@adrkit/mcp. The read-only MCP server, speaking both protocol eras and dogfooded against the published artifact on each via the official MCP Inspector.
A fifth package, @adrkit/spec-kit, versions independently at 0.1.3 and releases on its own tag, because an adapter's semver contract is with its upstream rather than with this project's core. It adds three namespaced commands to a Spec Kit project: pull the governing decisions into agent context before planning, check a produced plan against them, and scaffold a draft record from the plan artifact. Only the last one writes, and it is deliberately unreachable from the optional after_plan hook, because a plan-phase hook creating records unprompted would manufacture decision memory rather than record it.
Two surfaces ship outside npm entirely. A portable agent plugin installs from git into GitHub Copilot CLI, Claude Code, opencode, and anything APM targets, carrying a decision-memory skill, a read-only checker subagent, and four commands. And since v0.11.0 there's a source-built OCI image at ghcr.io/mbeacom/adrkit, multi-architecture, running as a non-root user on Node 24, with the CLI, the MCP server, and both CI entry points behind one dispatcher. @adrkit/ci isn't published to npm either. It's consumed directly as a GitHub Action from its repo path, which is how Actions are meant to be distributed.
The CLI installs two binaries now. adr is still primary, but @adrkit/cli also installs adrkit, because the bare name adr on npm belongs to an unrelated package published since 2017. The naming story is worth its own telling, including the part where npm refused to issue the unscoped name at all.
Honest status on the rest: the Phase 6 ARB queue, the Spec Kit extension, and the CI comment path are maintainer reference-verified against an isolated reference repository, but not yet externally validated. The project tracks that as an explicitly open item on a three-rung evidence ladder rather than rounding it up to "done." The later evaluator passes and pluggable catalog binding still aren't built. The record that specified all four passes carried a SOC 2 control and a standing commitment to publish escalation precision and recall every release, so ratifying it whole would have asserted a compliance obligation the project cannot yet compute. It was superseded instead by a narrower record that accepts the deterministic pass and defers the reporting obligation to the first probabilistic one.
One more constraint worth naming, since it bites projects like this constantly: Bun is a development dependency only. The repo builds with Bun, but nothing published requires it. The CLI, the Action, and the MCP server are all Node-targeted and smoke-tested under Node 22 and 24 in CI. Choosing a fast toolchain shouldn't export that choice to everyone who installs your package.
Dogfooding, and why it's load-bearing here
Every decision in adrkit is governed by adrkit. The repository's first commit is its own decision corpus. The evaluator rubric is itself an ADR, so changes to it go through the same review path as everything else. Records numbered through 0034 and still climbing, all in docs/adr/, all linted in CI by the tool they describe.
Dogfooding here isn't only self-referential, either. Two of my other projects now depend on adrkit as a real tool rather than a demo: openleague pins the CLI and the MCP server at exact versions, runs the Action on every pull request, and wires decision retrieval into three separate agent surfaces; context-kit built a bridge plugin around the corpus. That's where the interesting failures came from, and it's the subject of the companion post.
That's not a cute detail. A governance tool that its own authors don't submit to is a tool whose failure modes nobody has felt yet. Design commitments like "a clean clone with no credentials builds, tests, and lints green" and "every integration is an optional adapter; the core depends on none" each link back to the record that decided them, which means the claims in the README are checkable rather than aspirational.
The schema gets one further concession: schema/ is additionally released under CC0. If this format is worth anything, it should become a shared contract, and a competing implementation shouldn't have to think about licensing to adopt it.
Why this matters more now than it did two years ago
I wrote recently that agents are production actors and the SDLC needs a control plane — that a guardrail living in a prompt is a suggestion, while a guardrail living in branch protection is a control. adrkit is me taking my own argument seriously at the layer above code review.
The volume problem is real and getting worse. Agents generate architectural proposals faster than any review capacity you can staff, and the reflex (build a dashboard, add an approval console, stand up a separate system of record) creates a parallel control structure that immediately drifts from the one your organization actually enforces. The better answer is to make decisions legible to the systems you already run: git, pull requests, CODEOWNERS, CI.
An unenforceable ADR is a wish. A decision record that a linter, a resolver, an agent, and a CI job can all read is a different artifact entirely — and that's the whole thesis.
It's early, it's under active development, and I'd genuinely rather hear where it breaks than collect stars. If you're carrying a decision corpus that nobody reads or an ARB that's either a bottleneck or a rubber stamp, adr migrate --from madr is non-destructive and one-way. Try it against a copy and tell me what it gets wrong.
- Site: adrkit.dev: quickstart, command reference, MCP setup, CI usage
- Source: github.com/mbeacom/adrkit, Apache-2.0, schema additionally CC0
- Packages:
@adrkit/cli·@adrkit/core·@adrkit/evaluator·@adrkit/mcp·@adrkit/spec-kit
