Turn an ADR into an enforceable decision
Add a ## Constraints section to an architecture decision record (ADR) with a FORBID_LITERAL rule: the exact text it forbids and the paths it covers. Import the ADR, and mneme check fails any file under those paths that contains the text. The example is ADR-012: request handlers never import the database client.
You’ll end up with: an ADR that makes mneme check fail when a file under app/handlers/ imports the database client, plus a JSON verdict that records why.
Goal
An ADR (architecture decision record) explains a decision to the people who read it. A coding agent or a CI job never reads it. Mneme compiles the ADR into a decision: the ADR's text plus a typed rule naming the exact text it forbids and the paths where the ban applies.
Mneme stores compiled decisions in one JSON file, .mneme/project_memory.json. This guide calls it the memory file.
Prerequisites
- Python 3.11 or newer.
- Mneme 0.10.0, installed with pipx (new to Mneme? start with Install Mneme and activate your first protection):
pipx install "mneme-hq==0.10.0"
- A repository with a
docs/adr/directory. The example talks aboutapp/handlers/(request handlers),app/repositories/(data access) andapp/db/client.py(the database client). Those paths don't need to exist:mneme checkreads the file you pass and treats--target-pathas the path that content would have. - No coding agent. The next guide adds one.
Configure
1. Create the memory file
Run this from the repository root. It creates an empty memory file and refuses to overwrite an existing one.
mneme init
Created .mneme/project_memory.json
2. Write the ADR with a Constraints section
Save this as docs/adr/ADR-012-handlers-use-repositories.md. Mneme reads the YAML frontmatter and the ## Constraints section. It keeps the prose in between as the decision's rationale.
docs/adr/ADR-012-handlers-use-repositories.md---
id: ADR-012
title: Request handlers do not import the database client
status: accepted
priority: foundational
date: "2026-10-01"
scope: handlers
---
Handlers call repositories. A direct client import skips the transaction
and tenancy handling that lives in app/repositories.
## Constraints
- FORBID_LITERAL:
value: from app.db.client import
include_paths:
- "app/handlers/**"
status: acceptedputs the ADR in the active set, the decisions Mneme checks. The import leavesproposed,deprecatedandsupersededADRs out.- Changing an ADR's status later does not remove its decision from the memory file. To retire a decision, write a new ADR that lists the old one under
supersedes, keep the old file indocs/adr, and re-import with--update-existing(see Troubleshooting). Don’t edit the memory file by hand: from 0.10.0 a hand-edited file can fail to load. priorityis required. When two ADRs share a scope, the higher priority wins.scope: handlersnames the area the ADR governs, and lookup matches on it (see Give coding agents the decisions that apply to a task). Give every accepted ADR its own scope. When two share one, the import keeps only one of them; see Troubleshooting.FORBID_LITERALis the rule. Itsvalueis exact, case-sensitive text.include_pathslimits the rule to files underapp/handlers/. Without it, the rule applies to every file.
The ADR import reference lists every frontmatter field and directive.
3. Preview the import
Without --apply, the import writes nothing. Check that ADR-012 is in the active set and the rule reads the way you meant it.
mneme adr import docs/adr --memory .mneme/project_memory.json
ADR import preview
============================================================
Active set (1 ADRs):
[ADR-012] status=active
rule: FORBID_LITERAL from app.db.client import
include_paths: app/handlers/**
4. Apply it
mneme adr import docs/adr --memory .mneme/project_memory.json --apply
Wrote 1 decisions to .mneme/project_memory.json
Run
Here is a handler that breaks ADR-012. Save it anywhere, for example inputs/invoices_direct.py:
inputs/invoices_direct.pyfrom app.db.client import get_connection
def list_invoices(customer_id: str):
conn = get_connection()
return conn.execute("SELECT * FROM invoices WHERE customer_id = ?", (customer_id,))
Check it the way an agent hook (a check that runs inside a coding agent before it writes a file) or a CI job would. --input is the file to read. --target-path is the path that content will have in the repository, which is what the rule's include_paths are matched against. --query describes the change; Mneme uses it to choose which decisions to show as guidance, and checks typed rules such as this one either way.
mneme check --memory .mneme/project_memory.json \ --input inputs/invoices_direct.py \ --target-path app/handlers/invoices.py \ --query "edit to app/handlers/invoices.py"
Expected result
The check fails, names ADR-012, and exits with code 2:
FAIL [ADR-012] FORBID_LITERAL "from app.db.client import" -- trigger: from app.db.client import
Request handlers do not import the database client
path: app/handlers/invoices.py via app/handlers/**
PATH APPLIED [ADR-012] app/handlers/invoices.py via app/handlers/** -- an include selector matched
Result: FAIL
Run the same content against a repository path. ADR-012 doesn't apply there, so the check passes with exit code 0:
mneme check --memory .mneme/project_memory.json \ --input inputs/invoices_direct.py \ --target-path app/repositories/invoices.py \ --query "edit to app/repositories/invoices.py"
PATH EXCLUDED [ADR-012] app/repositories/invoices.py -- no include selector matched Result: PASS
A handler that calls the repository instead passes too. Save it as inputs/invoices_repo.py:
inputs/invoices_repo.pyfrom app.repositories.invoices import InvoiceRepository
def list_invoices(customer_id: str):
return InvoiceRepository().for_customer(customer_id)
mneme check --memory .mneme/project_memory.json \ --input inputs/invoices_repo.py \ --target-path app/handlers/invoices.py \ --query "edit to app/handlers/invoices.py"
PATH APPLIED [ADR-012] app/handlers/invoices.py via app/handlers/** -- an include selector matched Result: PASS
Evidence
Add --json to get a verdict a machine can read. Keep it as a CI artifact or attach it to the pull request.
mneme check --memory .mneme/project_memory.json \ --input inputs/invoices_direct.py \ --target-path app/handlers/invoices.py \ --query "edit to app/handlers/invoices.py" \ --json > verdict.json python -m json.tool verdict.json
{
"schema": "mneme.check/v1",
"verdict": "FAIL",
"mode": "strict",
"evaluation_complete": true,
"applicability": [
{
"decision_id": "ADR-012",
"rule_type": "FORBID_LITERAL",
"rule_value": "from app.db.client import",
"rule_index": 0,
"path_scoped": true,
"input_path": "app/handlers/invoices.py",
"outcome": "APPLIED",
"selector": "app/handlers/**",
"reason": "an include selector matched"
}
],
"violations": [
{
"decision_id": "ADR-012",
"decision_text": "Request handlers do not import the database client",
"severity": "FAIL",
"rule": "from app.db.client import",
"trigger": "from app.db.client import",
"kind": "typed_rule",
"rule_type": "FORBID_LITERAL",
"input_path": "app/handlers/invoices.py",
"selector": "app/handlers/**"
}
],
"freshness": []
}
verdictis PASS, WARN, FAIL or INCOMPLETE (the check could not finish).modeisstrictby default: FAIL and INCOMPLETE exit 2 and WARN exits 1. Pass--mode warnto report WARN and FAIL without failing; INCOMPLETE still exits 2.evaluation_completemust betruebefore you trust the verdict. An incomplete evaluation exits 2 and never reports PASS.applicabilityshows which path selector matched, so you can see why a rule applied or didn't.
The audit sorts every decision in the memory file into four tiers: Protected, Mneme-ready, Requires modelling and Guidance (Install Mneme and activate your first protection explains them). ADR-012 carries a rule that mneme check enforces, so the audit counts it as Protected:
mneme audit --memory .mneme/project_memory.json --repo-root .
Architecture Protection Audit
============================================================
Decisions discovered: 1
Protection-relevant: 1
Current Protection: 100.0%
Protection Gap: 0.0%
Protected today: 1
Mneme-ready: 0
Requires modelling: 0
Guidance-only: 0
Per-decision breakdown:
[protected] ADR-012: Request handlers do not import the database client
guardrail: FORBID_LITERAL: from app.db.client import
Troubleshooting
The check passes on a file that contains the import
You left out --target-path. Mneme then matches the rule against the input file's own path, inputs/invoices_direct.py, which isn't under app/handlers/:
PATH EXCLUDED [ADR-012] inputs/invoices_direct.py -- no include selector matched Result: PASS
Pass the repository path the content belongs to.
An ADR is missing from the active set
Two accepted ADRs share a scope. The import keeps the one with the higher priority, or the newer date, and drops the other without a warning. Here ADR-013 also uses scope: handlers:
troubleshooting/same-scope/ADR-013-handlers-no-http.md---
id: ADR-013
title: Request handlers do not call HTTP services directly
status: accepted
priority: foundational
date: "2026-10-02"
scope: handlers
---
Handlers call service clients in app/clients.
## Constraints
- FORBID_LITERAL:
value: import requests
include_paths:
- "app/handlers/**"
Import both, and ADR-012 disappears from the active set:
Active set (1 ADRs):
[ADR-013] status=active
rule: FORBID_LITERAL import requests
include_paths: app/handlers/**
Give each ADR its own scope. A dotted scope such as handlers.http keeps them apart, and both stay active. Dotted scopes change how the Model Context Protocol (MCP) lookup finds an ADR; Give coding agents the decisions that apply to a task explains.
Active set (2 ADRs):
[ADR-012] status=active
rule: FORBID_LITERAL from app.db.client import
include_paths: app/handlers/**
[ADR-013] status=active
rule: FORBID_LITERAL import requests
include_paths: app/handlers/**
The import stops with missing YAML frontmatter
The import crashes with a Python traceback and exit code 1. The last line names the file and the problem:
mneme.adr_schema.ADRParseError: troubleshooting/nygard/ADR-012.md: missing YAML frontmatter (expected file to start with '---')
The ADR uses a Nygard-style **Status:** Accepted line instead of YAML frontmatter:
troubleshooting/nygard/ADR-012.md# ADR-012: Request handlers do not import the database client **Status:** Accepted Handlers call repositories.
Mneme doesn't parse those. Add the frontmatter block from step 2 to the top of the file.
The import stops with unknown constraint directive
Same traceback, different last line:
mneme.adr_constraints.ConstraintParseError: unknown constraint directive 'FORBID_LITERALS' (expected one of ['FORBID_DEPENDENCY', 'FORBID_LITERAL', 'FORBID_PATH', 'REQUIRE_PATH'])
A directive name has a typo. Mneme stops the import instead of skipping the rule, so a misspelling can't switch enforcement off without anyone noticing.
ADR import refused: id 'ADR-012' already exists
ERROR: ADR import refused: id 'ADR-012' already exists in canonical decision authority. Pass --update-existing to create or reuse an immutable version occurrence, or rename the incoming ADR.
You applied the ADR before. After you edit it, re-run the import with --update-existing, which records the edit as a new version of the decision and keeps the earlier one:
mneme adr import docs/adr --memory .mneme/project_memory.json --apply --update-existing
On memory migrated from Mneme 0.9.x, the import also refuses to drop a rule that the migration carried over, so changing or removing one needs --preserve-protection or --release-protection for each rule it names. See adr import.
Limits
- The rule matches exact text.
import app.db.client as dbis different text, so this handler passes:File ·inputs/invoices_alias.pyimport app.db.client as db def list_invoices(customer_id: str): return db.get_connection().execute("SELECT * FROM invoices")Add oneRecorded outputPATH APPLIED [ADR-012] app/handlers/invoices.py via app/handlers/** -- an include selector matched Result: PASS
FORBID_LITERALper import form you need to block, or run an import linter in CI alongside Mneme. - Only
FORBID_LITERALgives a FAIL that respectsinclude_paths. A one-wordFORBID_DEPENDENCYvalue, likerequests, gives a WARN wherever the word appears, whatever the path. A name made of several words, likeleft-pad, is checked only when lookup picks that decision for the query. In strict mode a WARN exits 1, and the Claude Code hook blocks on it. Here is ADR-014, which bansrequests:File ·troubleshooting/dependency/ADR-014-no-requests.md--- id: ADR-014 title: Services use the shared HTTP client status: accepted priority: foundational date: "2026-10-03" scope: network --- Use app.clients.http so retries and timeouts stay consistent. ## Constraints - FORBID_DEPENDENCY: requests
Recorded outputWARN [ADR-014] constraint "no requests" -- trigger: requests Services use the shared HTTP client Result: WARNFORBID_PATHandREQUIRE_PATHare stored for guidance and the audit, and don't change a verdict. - This guide runs checks by hand. Stop an AI coding agent from crossing an architectural boundary runs the same check before an agent writes a file, and Fail a pull request that breaks an ADR runs it on every pull request.