Turn an ADR into an enforceable decision
Add a ## Constraints section to the ADR with a FORBID_LITERAL rule that names the exact text it forbids and the paths it covers, preview the import, then apply it. From then on mneme check fails any file under those paths that contains the text, with exit code 2, and passes the rest. 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. Every mneme check on those paths then gives the same verdict.
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.9.2, installed with pipx:
pipx install "mneme-hq==0.9.2"
- A repository with a
docs/adr/directory. The example code lives inapp/handlers/(request handlers),app/repositories/(data access) andapp/db/client.py(the database client). - 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.
---
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 import leavesproposed,deprecatedandsupersededADRs out. A decision that is already in the memory file stays there until you remove it.scope: handlersnames the area the ADR governs, and lookup matches on it (Guide 3). 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:
from 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 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.
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:
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.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 classifies every decision in the memory file. 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, and ADR-012 disappears:
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 MCP lookup finds an ADR; Guide 3 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. 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 target memory decisions[]. Pass --update-existing to overwrite, or rename the incoming ADR.
You applied the ADR before. After you edit it, re-run the import with --update-existing:
mneme adr import docs/adr --memory .mneme/project_memory.json --apply --update-existing
Limits
- The rule matches exact text.
import app.db.client as dbis different text, so this handler passes:PATH APPLIED [ADR-012] app/handlers/invoices.py via app/handlers/** -- an include selector matched Result: PASS
Add oneFORBID_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 that splits into several words, likeleft-pad, is checked only when lookup selects the decision. In strict mode a WARN exits 1, and the Claude Code hook blocks on it:WARN [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. Guide 2 runs the same check before an agent writes a file, and Guide 4 runs it on every pull request.