Mushroom, cloud, and code logo
Published on

An Unenforceable ADR Is a Wish: Building adrkit

Authors

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.

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.

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.

1
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.

2
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:

TierLabel
autoexpedited routing; human acceptance required
asyncasynchronous human review
arbARB 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.

A small rule with outsized consequences

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:

ToolPurpose
search_decisionsFiltered search across the corpus
get_decisionFetch one record by id
get_decision_context(files[])Decisions governing a set of files
list_supersededThe 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.

Built vs. specified — be clear about which is which

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, and ADR-0005 itself is still proposed, not accepted. 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.3.0 is on npm. Four published packages, all Node 22+ targeted:

  • @adrkit/core — parsing, validation, migration, and affects resolution. Pure library, no adapters.
  • @adrkit/cli — the adr binary: 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.

@adrkit/ci is the fifth workspace package but isn't published to npm — it's consumed directly as a GitHub Action from its repo path, which is how Actions are meant to be distributed.

Honest status on the rest: the Phase 6 ARB queue is 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 aren't built.

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. Eighteen records and counting, all in docs/adr/, all linted in CI by the tool they describe.

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.