Graph-integrity tooling for Architecture Decision Records — an MCP server and a CLI (same logic, one entry point) over a directory of ADR markdown files.
It treats the ADR corpus as a typed knowledge graph built from three reference channels:
- frontmatter typed edges —
supersedes,superseded_by,related - body wikilinks —
[[ADR-123]],[[123-slug|ADR-123]] - body markdown links —
[ADR-123](./123-slug.md)
This server standardizes on Google's Open Knowledge Format (OKF) v0.1 — an open, vendor-neutral format for representing knowledge as a directory of markdown files with YAML frontmatter. OKF is designed to be authored by people, generated by agents, exchanged across organizations, and consumed by both.
Required frontmatter: type (freeform — adr, playbook, metric, …)
Recommended frontmatter: title, description, resource, tags, timestamp
Every tool in this server reads and writes OKF-conformant documents. The migrate_okf
tool upgrades a legacy corpus to full conformance, and okf_conformance reports field
coverage and spec violations.
An incomplete graph is not automatically a broken one. Every "incomplete" finding is dispositioned as either an intentional frontier (a decision you'll connect later) or genuine rot:
| Finding | Intentional (signal) | Accidental (defect) |
|---|---|---|
| Singleton | disallow_singletons: false in policy and (status: proposed/draft/seed, standalone: true, or seed tag) |
Default case: disconnected node (defect) |
| Dead link | target listed in the node's planned: / forward_refs: |
undeclared reference to a missing ADR |
| Subject scope | subject_scope: outside the tree and discharged_by: says how it is observed |
subject_scope: outside the tree with no declared discharge |
| Cross-root ref | target resolves in a root listed in sibling_roots: |
target resolves nowhere |
validate returns ok: false on genuine rot — orphan singletons (by default), undeclared dead links,
broken reciprocity, OKF violations (missing type field), or undischarged subject scopes.
When a corpus policy sets disallow_singletons: false (or configures seed_statuses), intentional
singletons are excused as signals rather than failures.
A dead link is a claim whose target cannot be resolved. A decision can also make a claim whose subject cannot be observed — a control that lives in per-checkout state (a git hook, a local daemon) or in a deployed environment. Nothing in a source tree can establish that such a control is actually running, so a check that reports green over one is reporting on something it cannot see.
Two optional frontmatter keys make that declarable, and therefore dispositionable:
subject_scope: per-machine # commit (default) | deployment | per-machine
discharged_by: heartbeat:my-guard-ran # heartbeat | named-unverifiablecommit— the default, and the sound case: the subject is the tree at the SHA. Omit the key entirely and this is what you get. Never a defect.per-machine/deployment— the subject is not in the tree. The node must say how it becomes observable:heartbeat(the control emits a liveness signal and its silence alarms) ornamed-unverifiable(the blind spot is stated rather than papered over).- Declared without a discharge → defect. That is a claim with no observable subject, and it is the shape that lets an uninstalled control report green for months.
This is a disposition over a declared field. adr-graph reads what a node says about its
own subject; it never parses or evaluates a requirement predicate — that belongs to whatever
verifier your corpus uses.
hover_context resolves a file path to its governing ADRs by matching code_paths globs. A
lookup like that has three possible outcomes, not two, and collapsing them is how a tool ends
up making a confident false claim:
| provenance | meaning |
|---|---|
matched |
at least one code_paths glob matched this path |
no_explicit_match |
an index exists; this path is not in it — not indexed, not unconstrained |
no_code_paths_declared |
no ADR declares code_paths at all — the tool cannot answer, and a non-match carries no information whatsoever |
Graph.governing_adrs_with_provenance(file_path) returns
{query, provenance, result, matched_via, index_size, corpus_size}. result is None rather
than [] when there is no answer, because an empty collection collides with "the answer is
none". Graph.get_governing_adrs() still returns a plain list for callers that only need
matches — but anything reporting to a human or an agent should use the provenance form.
This is not hypothetical. Run against a 67-ADR corpus in which zero ADRs declared
code_paths, the previous implementation returned "No architectural decisions explicitly
govern this path" for every file in the repository — an assertion of absence from an index
that did not exist.
matched_via reports which pattern matched. code_paths uses fnmatch, where *
crosses /: src/* governs the entire subtree, and src/** behaves identically to src/*.
That behaviour is disclosed rather than silently changed, so an over-broad glob is visible at
the call site instead of quietly widening a decision's reach. Check the pattern before
treating a match as intentional.
Which statuses count as frontiers, which tags, and which scopes demand a discharge are all
corpus opinions. They are read from a policy node: any top-level markdown file in the root
whose frontmatter declares type: policy.
---
type: policy
title: Corpus disposition policy
disallow_singletons: false # allow intentional frontiers (defaults to true)
seed_statuses: [proposed, draft, seed]
seed_tags: [standalone, frontier]
scopes_requiring_discharge: [per-machine, deployment]
sibling_roots: [../../infrastructure/docs/adr, ../../web/docs/adr]
---sibling_roots matters more than it looks. A corpus is per-repo. A product with several
repos has several ADR roots, and a decision in one routinely cites a decision in another. Load
one root without declaring the others and every such reference is reported broken — because a
validator that cannot say "outside my root" says "missing" instead.
Measured on a real four-root corpus: 94 reported broken links, 73 distinct targets, all of
them resolving in a sibling root, none absent anywhere. Declaring sibling_roots moved that
count to zero and reclassified them as signals.cross_root_refs. If your corpus reports
alarming dead-link numbers, check this before believing them.
A node, not a .toml beside the repo — deliberately. A sidecar that goes missing falls back
to defaults silently, giving you a gate weaker than the one you declared with nothing to
notice it. A policy node's presence is a function of the tree SHA, so its absence is visible
to the same tooling that reports dead links, and changing it is a reviewable diff in the
corpus rather than an untracked config edit. validate reports which was used as
meta.policy_source ("defaults" when no node is present).
Keys you omit keep their documented defaults; keys you set replace them wholesale.
Point it at a corpus with ADR_GRAPH_ROOT, then register the stdio server:
{
"mcpServers": {
"adr-graph": {
"command": "adr-graph",
"env": { "ADR_GRAPH_ROOT": "/path/to/docs/adr" }
}
}
}Tools:
validate(root?): Full topology report.okf_conformance(root?): OKF v0.1 conformance report — violations, warnings, field coverage.find_singletons(root?): Intended frontiers vs orphan suspects.find_dead_links(root?): Planned vs broken dead links.check_reciprocity(root?): Mirroring checks for supersedes edges.neighbors(adr, depth?, root?): Authored-link neighborhood context.export(fmt?, root?): Render asjson,mermaid, orokf(bundle summary).supersede(superseding, superseded, root?): Atomically write supersession edges.reconcile_related(adr?, apply?, root?): Derive frontmatter related links from body refs.read(adr, root?): Read ADR body and metadata (includesdescription,resource).list(status?, tag?, limit?, offset?, root?): Filtered catalog of ADRs with pagination.search(query, status?, limit?, offset?, root?): Title substring search with pagination.path(from_adr, to_adr, root?): BFS shortest path.set_status(adr, status, root?): Single-field frontmatter update.rename(old, new, dry_run?, root?): Renumber/rename ADR and update all references.drift(root?): Disagreements between body links and frontmatter relations.blast_radius(adr, root?): Downstream transitive dependencies.propose_adr(title, status?, context?, tags?, root?): Scaffold a new ADR file.hover_context(file_path, root?): Return architectural context for a file path (matches againstcode_pathsglobs in ADR frontmatter).migrate_okf(dry_run?, root?): Migrate corpus to OKF v0.1 — adds type, converts date→timestamp, synthesizes descriptions, generatesindex.md. Dry-run by default.audit_diff(files?, git_diff?, root?): Actively audit code changes or files against governing ADR invariants (expect: present,expect: absent).task_briefing(task, files?, root?): Synthesize an architectural briefing, invariant checklist, downstream blast radius, and neighborhood context for an implementation task.coverage(source_dirs?, churn_days?, root?): Comprehensive codebase governance coverage report, identifying subsystems, high-churn shadow architecture (ungoverned files with frequent git commits), and stale code paths.scaffold_invariants(adr, apply?, root?): Inspect governed code files and automatically synthesize candidate<!-- adr:requirements -->blocks for class definitions, configs, and exported interfaces.install_hook(hook_type?, force?): Install a zero-config executable git pre-commit hook that validates graph integrity and audits staged changes against invariants before every commit.
Also exposes the adr://{adr_id} resource to get raw markdown content natively.
Same binary, with a subcommand. Exits non-zero on rot or invariant violations, so it drops straight into CI or a pre-commit hook:
adr-graph validate /path/to/docs/adr # exit 1 if broken links, singletons, or reciprocity breaks
adr-graph okf-conformance # OKF v0.1 field coverage and violations
adr-graph audit src/auth/config.py src/db/pool.py # audit files against governing ADR invariants (exit 1 on violation)
adr-graph briefing "Implement JWT refresh tokens" src/auth # synthesize architectural briefing & invariant checklist
adr-graph coverage # codebase coverage & high-churn shadow architecture report
adr-graph scaffold-invariants ADR-2 --apply # synthesize & append invariant block to ADR-2
adr-graph install-hook # install executable .git/hooks/pre-commit gate
adr-graph singletons # intentional frontier vs orphan suspects
adr-graph neighbors ADR-401 2 # authored-link neighbourhood (grounding context)
adr-graph reconcile ADR-3 --apply # derive frontmatter `related` from body links
adr-graph export mermaid # render for visualization
adr-graph export okf # OKF bundle metadata summary
adr-graph read ADR-3 # retrieve body text and frontmatter of ADR-3
adr-graph list --status proposed --limit 20 --offset 10 # list ADRs matching filters with pagination
adr-graph search "auth" --status accepted # search ADRs by title
adr-graph path ADR-1 ADR-5 # get BFS shortest path from ADR-1 to ADR-5
adr-graph set-status ADR-3 deprecated # update ADR status field
adr-graph rename ADR-3 ADR-30 --apply # cascade renumber ADR-3 to ADR-30
adr-graph drift # find nodes where yaml edges and body links disagree
adr-graph blast-radius ADR-3 # find all transitive dependents of ADR-3
adr-graph propose "Use PostgreSQL" "PostgreSQL spec" # scaffold a new ADR file
adr-graph migrate-okf --apply # migrate corpus to OKF (dry-run without --apply)Root resolution: explicit arg → ADR_GRAPH_ROOT → ./docs/adr.
pip install -e .