A local-first utility for durable, immutable decision records with explicit supersession. It preserves consequential choices — rejected alternatives, evidence, consequences, owner, review date, and affected projects — so later sessions recover why a decision was made without treating chats or current docs as history.
One decision per tracked Markdown file (YAML frontmatter + Markdown body); a derived JSON index serves fast queries but is never a second source of truth.
| Tool | Why |
|---|---|
| Python 3.12+ | Implementation language |
| uv | Environment + dependency management |
| argparse | CLI |
| pytest | Schema, golden, property, and subprocess tests |
| Ruff | Lint + format |
| mypy (strict) | Static typing |
- Python 3.12+
- uv
uv sync --extra dev
uv run pytest -q
uv run ruff check .
uv run mypy --strict srcpaper-trail add # create a decision record (id = YYYY-MM-DD-<kebab-slug>)
paper-trail list # list records
paper-trail show <id> # render one record
paper-trail supersede # create a successor, linked both directions
paper-trail audit # validate chains: cycles, forks, broken links
paper-trail index # rebuild the derived decisions/index.json (deterministic)
paper-trail digest # concise Markdown/JSON digestStorage defaults to <git-root>/decisions/ of the enclosing repository; --root <path> overrides.
No enclosing repository and no --root is an explicit error (never a heuristic fallback).
A step-by-step walkthrough of the full add → supersede → audit → digest lifecycle lives in docs/decision-authoring-guide.md.
- Markdown is authoritative. The JSON index (
decisions/index.json, gitignored, atomically rebuilt) is derived and may be regenerated; it never becomes a second source of truth. - Immutable history. Superseding creates a new record and links both directions through validated IDs. Existing decision meaning is never silently edited or deleted.
- Supersession fork policy.
superseded_byis scalar (at most one successor; a second supersede refuses; audit fails fan-out with both claimant IDs);supersedesis a list (fan-in for consolidation is legal). Cycles and dangling targets are always illegal. - High-value threshold. Required
consequencesandalternativesfields discourage logging trivial choices. The README's qualifying examples: architecture, policy, scope, cross-project contracts. - Schema versioning. Every record carries
schema_version: 1. Readers tolerate all versions at or below current forever (records are immutable), and refuse newer versions with an explicit error.
Decision records are YAML-frontmatter Markdown. The frozen frontmatter keys — schema_version,
id, title, status, owner, review_date, affected_projects, alternatives,
consequences, evidence, supersedes, superseded_by — are the format contract external
indexers read (any producer speaking the same format works identically). See plans/plan.md §5.
The authoritative schema lives in src/paper_trail/models.py (DecisionRecord); a record is
validated on construction, so an invalid record cannot exist. Load untrusted mappings (parsed
frontmatter) with DecisionRecord.from_dict, which enforces the schema-version reader policy first.
| Field | Type | Required | Rule |
|---|---|---|---|
schema_version |
int | yes (default 1) |
>= 1; readers tolerate <= 1, refuse newer with an explicit error |
id |
str | yes | YYYY-MM-DD-<slug>; real calendar date; slug [a-z0-9-], 1-50 chars, no leading/trailing/double hyphen |
title |
str | yes | non-empty |
status |
"active" | "superseded" |
yes | one of the two literals |
owner |
str | yes | non-empty |
review_date |
ISO date (YYYY-MM-DD) |
yes | valid calendar date; a datetime is rejected |
affected_projects |
list[str] | no | may be empty; entries non-empty |
alternatives |
list[str] | yes | at least 1 non-empty entry |
consequences |
list[str] | yes | at least 1 non-empty entry |
evidence |
list[{locator, content_hash?}] |
no | locator non-empty; content_hash, if present, is a 64-char SHA-256 hex digest (format-only; no verification command in v1) |
supersedes |
list[id] | no | each a valid id; fan-in (many predecessors) allowed |
superseded_by |
id | null | no | at most one successor; a valid id when set |
A record may not supersede or be superseded by itself. Every validation failure raises a specific
ValidationError (or UnsupportedSchemaVersionError for a newer schema_version); both derive from
SchemaError. The frozen v1 wire shape is pinned by tests/fixtures/golden_v1.json.
src/paper_trail/
models.py authoritative decision schema
store.py atomic record writes + reads
index.py deterministic rebuildable JSON index
graph.py supersession validation + chain resolution
render.py record + digest rendering
cli.py add/list/show/supersede/audit/digest
decisions/ tracked records; index.json is derived + gitignored
Record decisions that are architecture, policy, scope, or cross-project contracts —
choices a future session would otherwise have to reverse-engineer. Skip minor implementation
choices; the required consequences/alternatives fields are the friction that enforces this.