references/promotion.md
# Promoting drafts — mechanics
Promotion turns a draft into an accepted decision. **An accepted decision must never reference a
draft** (a breach). When promoting a draft that references other drafts, each reference to a draft
*outside the promoted set* is one of:
| Reference | Direction | Resolution |
| :----------------------------- | :-------- | :------------------------------------------------------------------------------------------- |
| `relates_to` (front-matter) | symmetric | **dereference** — drop here; the referenced draft gains your new counter in its `relates_to` |
| `superseded_by` (front-matter) | forward | **dereference** — drop here; the referenced draft gains your new counter in its `supersedes` |
| `supersedes` → a draft | backward | **blocking** — promote that draft first |
| prose `` `DRFT` `` in the body | content | **blocking** — promote that draft first |
`supersedes` → an existing **decision** is a real supersession, gated behind `--allow-replace`.
## What `promote` does
`promote <name…>` is advisory — it never silently promotes extras or rewrites refs:
| Situation | Result |
| :------------------------------------ | :---------------------------------------------------------------------------------------------------------------- |
| self-contained | promotes it |
| only **dereferenceable** refs | refuses; re-run `promote <name…> --deref` (shows the moves) |
| any **blocking** ref | refuses; prints the minimal blocking set to co-promote (your draft highlighted) **and a copy-paste agent prompt** |
| `supersedes` an existing **decision** | refuses; re-run `promote <name…> --allow-replace` |
- `promote <name…> --deref` inverts the dereferenceable edges, then promotes — refused if any
blocking ref is present.
- `promote <name…> --allow-replace` confirms archiving the decisions the draft `supersedes` (the IDs
are shown in the preview, so the flag is just intent).
- Promote several at once, space- or comma-separated. Refs *within* the set become counter↔counter
automatically.
**Invariant:** after any promotion, everything the promoted record pointed at refers back to it —
via the inverted edge (`--deref`) or a counter rewrite (co-promoted).
requirements-dev.txt
# Test deps for the decision-records skill. CI always installs pytest; the tool
# itself is dependency-free (standard library only).
pytest
scripts/decisions.py
#!/usr/bin/env python3
"""
Decision-records registry tool — generate docs/decisions/INDEX.md and the cross-reference
path links across the docs tree, validate it, mint draft ids, and promote a draft
into a numbered decision.
Repository layout — everything the convention owns lives under docs/decisions/:
docs/
decisions/ # the convention's namespace (the umbrella)
INDEX.md # GENERATED registry over accepted/ + archived/
README.md # human guide to the convention (scaffolded by install)
AGENTS.md # agent rules: decisions are binding (scaffolded by install)
_template.md # decision-record template (numbered)
accepted/ # ACCEPTED numbered decisions
architecture/ product/ security/ # one subdir per type (alphabetical)
archived/ # RETIRED numbered decisions (superseded | deprecated) — flat
drafts/ # WIP candidates — flat, 4-UPPERCASE-letter ids, NOT in INDEX
_template.md
threat-model.md # other repo docs stay siblings; still cross-ref decisions
Lifecycle (there is NO "proposed" status — proposing is the *act* of opening a PR):
decisions/drafts/<AAAA-title>.md
--(promote: a PR assigns the next counter)--> decisions/accepted/<type>/NNNN-title.md (accepted)
decisions/accepted/<type>/NNNN --(supersede/deprecate)--> decisions/archived/NNNN-title.md
Conventions this tool encodes and enforces:
* Identity is the global counter ID (`0001`, …) for decisions; a 4-UPPERCASE-letter
ID (`CONF`) for drafts — mint a mnemonic of the draft's topic; `check` enforces
format + uniqueness. Permanent and canonical; the only thing cross-refs use.
* `type` is any lowercase slug — the set is OPEN; your accepted/<type>/ subdirs are the
suggested set (architecture/product/security, or policy/legal/finance for governance).
It lives in front-matter and, for a decision, equals its directory. `status` -> lifecycle.
* Cross-reference by writing the bare ID as inline code — `0006` or `CONF`. NEVER
hand-author a path. `build --relink` rewrites every such ID (in every docs/*.md —
records, drafts, and other docs) into a correct relative link and self-heals on moves.
* An accepted decision may NEVER reference a draft (a breach); `promote` therefore
promotes a whole reference-closure together and refuses a set that would breach.
* INDEX.md and every path link are GENERATED build artifacts.
Dependency-free (no PyYAML). Usage (a bare invocation = `build`; draft IDs are 4 UPPERCASE letters):
python scripts/decisions.py build # write docs/decisions/INDEX.md
python scripts/decisions.py build --relink # also refresh path links everywhere
python scripts/decisions.py check # validate only; exit 1 if stale (CI)
python scripts/decisions.py rename-draft-id <name> <NEW> # re-ID a draft, repoint refs
python scripts/decisions.py promote CONF [TIER ...] # promote draft(s) -> accepted/
python scripts/decisions.py promote CONF --deref # invert refs, promote alone
python scripts/decisions.py promote CONF --allow-replace # also archive what it supersedes
python scripts/decisions.py install [repo] # adopt: symlink + scaffold + check
"""
from __future__ import annotations
import argparse
import difflib
import os
import re
import sys
from pathlib import Path
def find_docs(start: Path | None = None) -> Path:
"""Locate the repo's docs/ by walking up from the CWD (not from __file__ — the
script may be a symlink installed by the decision-records skill). Falls back to a
path relative to this file for the unusual case of no match."""
cur = (start or Path.cwd()).resolve()
for p in (cur, *cur.parents):
if (p / "docs" / "decisions").is_dir():
return p / "docs"
return Path(__file__).resolve().parent.parent / "docs"
# A type is any lowercase slug — the set is OPEN. The repo's accepted/<type>/ subdirs are
# the suggested set (e.g. architecture, product, security; or policy, legal, finance,
# people, compliance, operations for governance repos). New types create their dir on promotion.
TYPE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
ACTIVE_STATUS = {"accepted"} # -> decisions/accepted/
RETIRED_STATUS = {"superseded", "deprecated"} # -> decisions/archived/
BLOCK_SCALAR_RE = re.compile(r"^([>|])[0-9+-]*$") # folded (>) / literal (|), chomp/indent mods
RECORD_RE = re.compile(r"^\d{4}-.+\.md$") # NNNN-kebab-title.md (decisions, archived)
DRAFT_ID_RE = re.compile(r"^[A-Z]{4}$") # 4-uppercase-letter draft ID
# An inline-code ID — a counter (`0006`) or a draft tag (`CONF`) — not already linked.
ID_RE = re.compile(r"(?<!\[)`(\d{4}|[A-Z]{4})`")
COUNTER_RE = re.compile(r"(?<!\[)`(\d{4})`") # numeric-only, for prose validation
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
LABEL_ID_RE = re.compile(r"`?(\d{4}|[A-Z]{4})`?$") # link label that is just an ID
FILE_ID_RE = re.compile(r"^(\d{4}|[A-Z]{4})-") # leading ID in a filename
STATUS_ICON = {"accepted": "🟢", "deprecated": "⚪", "superseded": "🔵"}
FENCE_RE = re.compile(r"^\s*(```|~~~)")
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
def strip_fences(text: str) -> str:
"""Blank out fenced code blocks, preserving line structure."""
out: list[str] = []
fence = None
for line in text.splitlines():
m = FENCE_RE.match(line)
if fence is not None:
if m and m.group(1) == fence:
fence = None
out.append("")
elif m:
fence = m.group(1)
out.append("")
else:
out.append(line)
return "\n".join(out)
def strip_code(text: str) -> str:
"""Blank out fenced blocks and inline code spans — a link inside code is syntax on
display, not a live link, so it must not be link-checked."""
text = strip_fences(text)
text = re.sub(r"``[^`]*``", "", text)
return re.sub(r"`[^`\n]*`", "", text)
def heading_anchors(text: str) -> set[str]:
"""GitHub-style anchor slugs for every heading, -N suffixes for duplicates."""
slugs: set[str] = set()
seen: dict[str, int] = {}
for line in strip_fences(text).splitlines():
m = HEADING_RE.match(line)
if not m:
continue
s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", m.group(2)) # links -> label
s = s.replace("`", "").strip().lower()
s = re.sub(r"[^\w\- ]", "", s).replace(" ", "-")
n = seen.get(s, 0)
seen[s] = n + 1
slugs.add(s if n == 0 else f"{s}-{n}")
return slugs
# ── layout ──────────────────────────────────────────────────────────────────
# Everything the convention owns nests under docs/decisions/ (the umbrella) so the three
# lifecycle dirs and the INDEX never collide with the repo's other docs/. `root` is docs/;
# other repo docs (threat-model.md, …) stay its direct children and keep their auto-links.
def decisions_dir(root: Path) -> Path:
return root / "decisions"
def accepted_dir(root: Path) -> Path:
return root / "decisions" / "accepted"
def archived_dir(root: Path) -> Path:
return root / "decisions" / "archived"
def drafts_dir(root: Path) -> Path:
return root / "decisions" / "drafts"
def index_path(root: Path) -> Path:
return root / "decisions" / "INDEX.md"
# ── parsing ─────────────────────────────────────────────────────────────────
def _consume_block_scalar(
lines: list[str], start: int, key_indent: int, folded: bool
) -> tuple[str, int]:
"""Consume a YAML block scalar (folded `>` / literal `|`) whose `key:` line sits at
`key_indent`. Continuation lines are those indented past `key_indent`; the first
such line sets the block's indentation, which is stripped from every line. Returns
(joined text, index of the first line after the block)."""
block: list[str] = []
indent: int | None = None
idx = start
while idx < len(lines):
raw = lines[idx]
if not raw.strip():
block.append("")
idx += 1
continue
cur_indent = len(raw) - len(raw.lstrip(" "))
if cur_indent <= key_indent:
break
if indent is None:
indent = cur_indent
block.append(raw[indent:])
idx += 1
while block and block[-1] == "":
block.pop()
if not folded:
return "\n".join(block), idx
paragraphs, para = [], []
for line in block:
if line == "":
if para:
paragraphs.append(" ".join(para))
para = []
else:
para.append(line)
if para:
paragraphs.append(" ".join(para))
return "\n\n".join(paragraphs), idx
def parse_front_matter(text: str) -> dict:
if not text.startswith("---"):
raise ValueError("missing front-matter")
end = text.index("\n---", 3)
lines = text[3:end].splitlines()
data: dict = {}
idx = 0
while idx < len(lines):
raw = lines[idx]
line = raw.split(" #", 1)[0].rstrip()
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
idx += 1
continue
key, _, val = line.partition(":")
key, val = key.strip(), val.strip()
block = BLOCK_SCALAR_RE.match(val)
if block:
key_indent = len(raw) - len(raw.lstrip(" "))
folded = block.group(1) == ">"
data[key], idx = _consume_block_scalar(lines, idx + 1, key_indent, folded)
continue
if val in ("", "null", "~"):
data[key] = None if val in ("null", "~") else ""
elif val.startswith("[") and val.endswith("]"):
inner = val[1:-1].strip()
data[key] = [i.strip().strip('"').strip("'") for i in inner.split(",") if i.strip()]
else:
data[key] = val.strip('"').strip("'")
idx += 1
return data
def front_matter_errors(recs: list[dict], drafts: list[dict]) -> list[str]:
"""Plain-scalar YAML hazards that a spec-compliant parser (e.g. GitHub's renderer)
rejects but the tolerant parser above accepts — chiefly an unquoted ": " inside a
value, which YAML reads as a nested mapping and errors on mid-scalar."""
errs = []
for r in recs + drafts:
path = r["_path"]
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
continue
lines = text[3 : text.index("\n---", 3)].splitlines()
idx = 0
while idx < len(lines):
raw = lines[idx]
line = raw.split(" #", 1)[0].rstrip()
idx += 1
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
continue
key, _, val = line.partition(":")
key, val = key.strip(), val.strip()
if BLOCK_SCALAR_RE.match(val): # block scalars may contain anything
key_indent = len(raw) - len(raw.lstrip(" "))
while idx < len(lines):
nxt = lines[idx]
if nxt.strip() and (len(nxt) - len(nxt.lstrip(" "))) <= key_indent:
break
idx += 1
continue
if not val or val in ("null", "~") or val[0] in "[{":
continue
if val[0] in "\"'":
if len(val) < 2 or val[-1] != val[0]:
errs.append(f"{path.name}: front-matter '{key}' has an unbalanced quote")
continue
if ": " in val or val.endswith(":"):
errs.append(
f"{path.name}: front-matter '{key}' has an unquoted ':' in its value —"
" strict YAML parsers (GitHub) reject this; quote the value or rephrase"
)
return errs
def split_front_matter(text: str) -> tuple[str, str]:
"""(head, body). Files without front-matter (e.g. threat-model.md) are all body."""
if not text.startswith("---"):
return "", text
fence = text.index("\n---", 3)
nl = text.index("\n", fence + 1)
return text[: nl + 1], text[nl + 1 :]
def set_field(text: str, key: str, value: str) -> str:
head, body = split_front_matter(text)
line = f"{key}: {value}" if value != "" else f"{key}:" # empty -> no trailing space
pat = re.compile(rf"^{re.escape(key)}:.*$", re.M)
if pat.search(head):
head = pat.sub(lambda _: line, head, count=1)
else:
head = head[:-4] + line + "\n---\n"
return head + body
def drop_field(text: str, key: str) -> str:
head, body = split_front_matter(text)
return re.sub(rf"^{re.escape(key)}:.*\n", "", head, flags=re.M) + body
# ── loading ─────────────────────────────────────────────────────────────────
def _meta(p: Path, **extra) -> dict:
text = p.read_text(encoding="utf-8")
fm = parse_front_matter(text)
fm.update(_file=p.name, _path=p, _text=text, **extra)
return fm
def load_records(root: Path) -> list[dict]:
"""Numbered records: accepted/<type>/*.md (typed) and archived/*.md (flat)."""
recs = []
for p in sorted(accepted_dir(root).rglob("*.md")):
if RECORD_RE.match(p.name):
recs.append(_meta(p, _lifecycle="decisions", _typedir=p.parent.name))
base = archived_dir(root)
if base.exists():
for p in sorted(base.glob("*.md")):
if RECORD_RE.match(p.name):
recs.append(_meta(p, _lifecycle="archived", _typedir=None))
return recs
def load_drafts(root: Path) -> list[dict]:
base = drafts_dir(root)
return (
[_meta(p) for p in sorted(base.glob("*.md")) if not p.name.startswith("_")]
if base.exists()
else []
)
def ref_map(recs: list[dict], drafts: list[dict]) -> dict:
"""ID -> path, for both counters and draft IDs (the universe of cross-ref targets)."""
m = {r["id"]: r["_path"] for r in recs}
for d in drafts:
m.setdefault(d.get("id"), d["_path"])
return m
def reference_targets(
root: Path, recs: list[dict], drafts: list[dict]
) -> list[tuple[Path, str | None]]:
"""Every docs/*.md whose body carries generated cross-reference links — records,
drafts, and any other doc (threat-model, roadmap, …). Pairs (path, own_id); own_id is
the record/draft ID (so it doesn't self-link), else None."""
own = {r["_path"]: r["id"] for r in recs}
own.update({d["_path"]: d.get("id") for d in drafts})
return [(p, own.get(p)) for p in _md_files(root)]
def next_counter(recs: list[dict]) -> str:
nums = [int(r["id"]) for r in recs if str(r.get("id", "")).isdigit()]
return f"{(max(nums) + 1) if nums else 1:04d}"
# ── links ───────────────────────────────────────────────────────────────────
def rel(target: Path, start: Path) -> str:
return os.path.relpath(target, start).replace(os.sep, "/")
def relink(text: str, refs: dict, own_id: str | None, self_dir: Path) -> str:
"""Render inline-code IDs (counters and draft tags) as relative links, looked up
from `refs`. Refresh existing links first (moves self-heal), then linkify bare
ones. Front-matter untouched. Idempotent."""
head, body = split_front_matter(text)
def fix_existing(m: re.Match) -> str:
lm = LABEL_ID_RE.fullmatch(m.group(1).strip())
if lm and lm.group(1) in refs and lm.group(1) != own_id:
rid = lm.group(1)
return f"[`{rid}`]({rel(refs[rid], self_dir)})"
return m.group(0)
def add_link(m: re.Match) -> str:
rid = m.group(1)
if rid == own_id or rid not in refs:
return m.group(0)
return f"[`{rid}`]({rel(refs[rid], self_dir)})"
body = LINK_RE.sub(fix_existing, body)
body = ID_RE.sub(add_link, body)
return head + body
def check_links(root: Path, recs: list[dict], drafts: list[dict], refs: dict) -> list[str]:
errs = []
files = [
(p.name, p, p.read_text(encoding="utf-8")) for p, _ in reference_targets(root, recs, drafts)
]
index = index_path(root)
if index.exists():
files.append((index.name, index, index.read_text(encoding="utf-8")))
anchors_cache: dict[Path, set[str]] = {}
def anchors_of(p: Path) -> set[str]:
rp = p.resolve()
if rp not in anchors_cache:
anchors_cache[rp] = heading_anchors(rp.read_text(encoding="utf-8"))
return anchors_cache[rp]
for name, path, text in files:
for _, target in LINK_RE.findall(strip_code(text)):
t, _sep, frag = target.partition("#")
t = t.strip()
if re.match(r"[a-z][a-z0-9+.-]*://", t) or t.startswith("mailto:"):
continue
if "…" in target or " " in t:
continue
if not t and not frag:
continue
dest = path.parent / t if t else path
if not dest.exists():
errs.append(f"{name}: broken link -> {target}")
continue
if frag and dest.suffix == ".md" and frag not in anchors_of(dest):
errs.append(f"{name}: broken anchor -> {target}")
continue
if not t:
continue
cm = FILE_ID_RE.match(os.path.basename(t))
if cm:
rid = cm.group(1)
if rid not in refs:
errs.append(f"{name}: link to unknown ID {rid} ({target})")
elif dest.resolve() != refs[rid].resolve():
errs.append(f"{name}: stale link for {rid} -> {target}")
return errs
# ── validation ──────────────────────────────────────────────────────────────
def validate_records(recs: list[dict], refs: dict) -> list[str]:
errs, ids = [], {}
for r in recs:
rid = r.get("id")
if rid in ids:
errs.append(f"duplicate ID {rid}: {r['_file']} and {ids[rid]['_file']}")
ids[rid] = r
summary = r.get("summary")
if summary and BLOCK_SCALAR_RE.match(str(summary).strip()):
errs.append(f"{r['_file']}: summary is an unresolved YAML scalar indicator {summary!r}")
if r["_lifecycle"] == "decisions":
if not TYPE_RE.match(str(r.get("type") or "")):
errs.append(f"{r['_file']}: type {r.get('type')!r} must be a lowercase slug")
elif r["_typedir"] != r.get("type"):
errs.append(
f"{r['_file']}: in accepted/{r['_typedir']}/ but type is {r.get('type')}"
)
if r.get("status") not in ACTIVE_STATUS:
errs.append(
f"{r['_file']}: in accepted/ but status {r.get('status')} is not accepted"
)
elif r.get("status") not in RETIRED_STATUS:
errs.append(f"{r['_file']}: in archived/ but status {r.get('status')} is not retired")
nums = sorted(int(r["id"]) for r in recs if str(r.get("id", "")).isdigit())
if nums:
missing = sorted(set(range(1, nums[-1] + 1)) - set(nums))
if missing:
errs.append("gap in counters — missing " + ", ".join(f"{n:04d}" for n in missing))
for r in recs:
for ref in (r.get("relates_to") or []) + [r.get("supersedes"), r.get("superseded_by")]:
if ref and ref not in refs:
errs.append(f"{r['_file']}: dangling reference {ref}")
return errs
def validate_drafts(root: Path, refs: dict, drafts: list[dict]) -> list[str]:
errs, seen = [], {}
for d in drafts:
did = d.get("id")
if not did or not DRAFT_ID_RE.match(str(did)):
errs.append(f"drafts/{d['_file']}: ID must be 4 UPPERCASE letters (got {did!r})")
elif did in seen:
errs.append(f"drafts/{d['_file']}: duplicate draft ID {did} (also {seen[did]})")
else:
seen[did] = d["_file"]
if not TYPE_RE.match(str(d.get("type") or "")):
errs.append(f"drafts/{d['_file']}: type {d.get('type')!r} must be a lowercase slug")
for ref in (d.get("relates_to") or []) + [d.get("supersedes"), d.get("superseded_by")]:
if ref and ref not in refs:
errs.append(f"drafts/{d['_file']}: references unknown ID {ref}")
return errs
def warn_unknown_types(root: Path, drafts: list[dict]) -> list[str]:
"""Non-blocking: a draft whose type has no accepted/<type>/ dir yet — likely a typo,
or a deliberately new type (its dir is created on promotion)."""
base = accepted_dir(root)
known = {p.name for p in base.iterdir() if p.is_dir()} if base.exists() else set()
return [
f"WARN drafts/{d['_file']}: new type {d['type']!r} — no accepted/{d['type']}/ yet "
"(typo? otherwise it's created on promotion)"
for d in drafts
if TYPE_RE.match(str(d.get("type") or "")) and d.get("type") not in known
]
def draft_references(d: dict, draft_ids: set) -> set:
"""The draft IDs this record points at — and that would survive into a decision:
front-matter relates_to/supersedes/superseded_by, plus body inline-code IDs whether
bare (`CONF`) or already markdown-linked ([`CONF`](...)). Linked refs are counted too,
so a relinked decision body cannot smuggle a draft reference past the breach check."""
refs = set(d.get("relates_to") or [])
for k in ("supersedes", "superseded_by"):
if d.get(k):
refs.add(d[k])
_, body = split_front_matter(d["_text"])
refs |= set(ID_RE.findall(body)) # bare inline-code IDs
for label, _ in LINK_RE.findall(body): # ...and markdown-linked IDs
m = LABEL_ID_RE.fullmatch(label.strip())
if m:
refs.add(m.group(1))
return refs & draft_ids
def validate_no_breach(recs: list[dict], drafts: list[dict]) -> list[str]:
"""An accepted/retired decision must NOT reference a draft — that would bind a
finalized record to unfinalized WIP. Promote the draft first."""
draft_ids = {d.get("id") for d in drafts}
errs = []
for r in recs:
for b in sorted(draft_references(r, draft_ids)):
errs.append(f"{r['_file']}: decision references draft {b} (breach — promote {b} first)")
return errs
def prose_unknown_counters(
root: Path, recs: list[dict], drafts: list[dict], refs: dict
) -> list[str]:
"""Un-linkified numeric counters in prose must resolve (4-letter tokens are not
flagged — they are ordinary words unless they match a known draft ID)."""
errs = []
for path, own in reference_targets(root, recs, drafts):
_, body = split_front_matter(path.read_text(encoding="utf-8"))
for cid in COUNTER_RE.findall(body):
if cid != own and cid not in refs:
errs.append(f"{path.name}: prose references unknown record {cid}")
return errs
# ── render ──────────────────────────────────────────────────────────────────
def render_index(recs: list[dict], root: Path) -> str:
recs = sorted(recs, key=lambda r: r["id"])
base = decisions_dir(root) # INDEX.md lives here; links are relative to it
rows = (
"\n".join(
f"| [{r['id']}]({rel(r['_path'], base)}) | {r.get('type', '')} | {r.get('summary', '')}"
f" | {STATUS_ICON.get(r.get('status', ''), '—')} | {', '.join(r.get('tags') or [])} |"
for r in recs
)
or "| _none_ | | _no decisions yet — promote a draft_ | | |"
)
return f"""# Decision Records — Index
<!-- GENERATED by scripts/decisions.py — do not edit by hand. -->
**Agents read this file first**, then load only the records relevant to the task
(filter on `type`, `status`, `tags`). Skip `archived/` (`superseded`/`deprecated`) unless
tracing why a decision changed. Records are held firm once `accepted` — course changes only by
supersession; decider-approved maintenance edits are allowed.
## Identity & references
- Identity is the global counter (`0001`, …), assigned in creation order. Permanent;
the only thing a human writes to cross-reference. Drafts use a 4-UPPERCASE-letter ID
instead — a mnemonic of the draft's topic.
- Reference by ID. Path links — each ID rendered as a markdown link to its file — are
**generated** by `scripts/decisions.py build --relink` and refreshed on every move,
so they stay correct; never **hand-author** a path.
- `type` is an open lowercase slug — the `accepted/<type>/` subdirs are the set (e.g.
architecture, product, security; or policy, legal, finance). For a decision it equals its
directory and `status` selects the lifecycle dir; the tool enforces placement, not a fixed list.
## Status: 🟢 accepted · ⚪ deprecated · 🔵 superseded
| ID | Type | Summary | Status | Tags |
|:---|:-----|:--------|:------:|:-----|
{rows}
## Layout
Everything lives under `docs/decisions/`:
- `accepted/<type>/`: accepted numbered records, one subdir per type.
- `archived/`: retired records (`superseded`/`deprecated`), flat.
- `drafts/`: 4-letter WIP candidates, flat and not indexed.
Moving a record never breaks references, whether you reclassify it or retire it into `archived/`.
Records are keyed by ID, not path, and `build --relink` refreshes the generated links.
"""
# ── promote / rename ────────────────────────────────────────────────────────
def _md_files(root: Path) -> list[Path]:
"""Every markdown file under docs/ whose links the tool manages — all `*.md` EXCEPT
template files (`_*.md`) and the generated `INDEX.md`."""
return [
p for p in sorted(root.rglob("*.md")) if not p.name.startswith("_") and p.name != "INDEX.md"
]
def rewrite_reference(root: Path, old: str, new: str) -> None:
"""Repoint every reference from ID `old` to ID `new` across the tree — front-matter
ref fields (de-duplicated) and inline-code body refs. Used when promotion changes a
draft's ID so inbound references don't dangle. `build --relink` then re-paths them."""
link = re.compile(r"\[`" + re.escape(old) + r"`\]\([^)]*\)")
bare = re.compile(r"`" + re.escape(old) + r"`")
def fix_field(m: re.Match) -> str:
key, val = m.group(1), m.group(2).strip()
if val.startswith("[") and val.endswith("]"):
out: list[str] = []
for i in (x.strip().strip('"').strip("'") for x in val[1:-1].split(",") if x.strip()):
i = new if i == old else i
if i not in out:
out.append(i)
return f"{key} [" + ", ".join(f'"{x}"' for x in out) + "]"
return f"{key} " + (f'"{new}"' if val.strip('"').strip("'") == old else val)
for p in _md_files(root):
t = p.read_text(encoding="utf-8")
head, body = split_front_matter(t)
head = re.sub(
r"^(relates_to:|supersedes:|superseded_by:)(.*)$", fix_field, head, flags=re.M
)
body = bare.sub(f"`{new}`", link.sub(f"`{new}`", body))
if head + body != t:
p.write_text(head + body, encoding="utf-8")
def match_drafts(root: Path, query: str) -> list[Path]:
drafts = (
[p for p in sorted(drafts_dir(root).glob("*.md")) if not p.name.startswith("_")]
if drafts_dir(root).exists()
else []
)
q = query.lower().strip()
for p in drafts: # an exact draft-ID match wins outright (no substring noise)
if q == str(parse_front_matter(p.read_text(encoding="utf-8")).get("id", "")).lower():
return [p]
hits = []
for p in drafts:
fm = parse_front_matter(p.read_text(encoding="utf-8"))
hay = f"{fm.get('id', '')} {fm.get('title', '')} {p.stem}".lower()
if q in hay:
hits.append(p)
if not hits:
names = {p.stem.lower(): p for p in drafts}
hits = [names[m] for m in difflib.get_close_matches(q, list(names), n=5, cutoff=0.4)]
seen, out = set(), []
for p in hits:
if p not in seen:
seen.add(p)
out.append(p)
return out
def select_draft(root: Path, query: str, input_fn=input) -> tuple[Path | None, str | None]:
"""Resolve a draft from a fuzzy query, prompting to disambiguate if several match."""
matches = match_drafts(root, query)
if not matches:
return None, f"no draft matches {query!r}"
if len(matches) == 1:
return matches[0], None
for i, p in enumerate(matches, 1):
print(f" [{i}] {p.name}", file=sys.stderr)
try:
raw = input_fn(f"pick a candidate [1-{len(matches)}]: ").strip()
except EOFError:
return None, "ambiguous match and no input — pass the exact draft ID"
if not raw.isdigit() or not (1 <= int(raw) <= len(matches)):
return None, "no candidate selected"
return matches[int(raw) - 1], None
def add_to_list_field(text: str, key: str, value: str) -> str:
head, _ = split_front_matter(text)
m = re.search(rf"^{key}:(.*)$", head, re.M)
items: list[str] = []
if m:
cur = m.group(1).strip()
if cur.startswith("[") and cur.endswith("]"):
items = [x.strip().strip('"').strip("'") for x in cur[1:-1].split(",") if x.strip()]
elif cur not in ("", "null", "~"):
items = [cur.strip('"').strip("'")]
if value not in items:
items.append(value)
return set_field(text, key, "[" + ", ".join(f'"{i}"' for i in items) + "]")
def remove_from_list_field(text: str, key: str, value: str) -> str:
head, _ = split_front_matter(text)
m = re.search(rf"^{key}:(.*)$", head, re.M)
if not m:
return text
cur = m.group(1).strip()
if cur.startswith("[") and cur.endswith("]"):
items = [x.strip().strip('"').strip("'") for x in cur[1:-1].split(",") if x.strip()]
items = [i for i in items if i != value]
return set_field(text, key, "[" + ", ".join(f'"{i}"' for i in items) + "]")
if cur.strip('"').strip("'") == value:
return set_field(text, key, "") # blank, not null (mdformat-stable; reads the same)
return text
def classify_refs(d: dict, draft_ids: set, decision_ids: set, in_set: set) -> dict:
"""Bucket a draft's references to things OUTSIDE the promote set:
deref - front-matter relates_to/superseded_by to a draft (invertible)
block - `supersedes` to a draft, or a prose `DRFT` ref (can't invert)
supersedes_dec - `supersedes` to an existing decision (the --allow-replace case)"""
sup = d.get("supersedes")
relates = (set(d.get("relates_to") or []) & draft_ids) - in_set
superby = (({d["superseded_by"]} if d.get("superseded_by") else set()) & draft_ids) - in_set
supdraft = (({sup} if sup else set()) & draft_ids) - in_set
_, body = split_front_matter(d["_text"])
prose = (set(ID_RE.findall(body)) & draft_ids) - in_set
block = supdraft | prose
return {
"deref": (relates | superby) - block,
"block": block,
"supersedes_dec": ({sup} & decision_ids) if sup else set(),
}
def blocking_closure(by_id: dict, seeds: set, decision_ids: set) -> set:
"""Seeds + every draft reachable by BLOCKING edges — the minimal set that must be
promoted together (dereferenceable edges no longer pull drafts in)."""
draft_ids, seen, stack = set(by_id), set(seeds), list(seeds)
while stack:
for ref in classify_refs(by_id[stack.pop()], draft_ids, decision_ids, set())["block"]:
if ref not in seen:
seen.add(ref)
stack.append(ref)
return seen
def _highlight(ids: set, seeds: set) -> str:
return "\n".join(f" → {i} (requested)" if i in seeds else f" {i}" for i in sorted(ids))
def _blocking_message(bclosure: set, seeds: set, block: set) -> str:
cmd = "python scripts/decisions.py promote " + " ".join(sorted(bclosure))
prompt = (
f"Promote draft(s) {', '.join(sorted(seeds))} with scripts/decisions.py. They are "
f"blocked: as accepted decisions they would reference draft(s) "
f"{', '.join(sorted(block))} via prose or `supersedes`, which cannot be "
f"dereferenced. Either co-promote the whole set — `{cmd}` — or restructure the "
f"draft(s) to cite only accepted decisions (counters). Then run "
f"`python scripts/decisions.py check`."
)
return (
"blocked: a promoted decision would reference draft(s) via prose or `supersedes`,\n"
"which can't be dereferenced. Promote the whole blocking set together:\n"
+ _highlight(bclosure, seeds)
+ f"\n\nRun:\n {cmd}\n"
+ "\nOr copy this prompt to an agent to fix it:\n"
+ "─" * 70
+ "\n"
+ prompt
+ "\n"
+ "─" * 70
)
def _deref_message(deref: set, seeds: set) -> str:
cmd = "python scripts/decisions.py promote " + " ".join(sorted(seeds)) + " --deref"
return (
"dereferenceable: the only cross-draft refs are front-matter "
f"relates_to/superseded_by to {', '.join(sorted(deref))}.\n"
"Re-run with --deref to move those links onto the referenced draft(s) (they point\n"
f"back once promoted):\n {cmd}"
)
def _allow_replace_message(need: set, seeds: set) -> str:
cmd = "python scripts/decisions.py promote " + " ".join(sorted(seeds)) + " --allow-replace"
return (
f"promoting {', '.join(sorted(seeds))} would supersede existing decision(s) "
f"{', '.join(sorted(need))},\nwhich will be archived. Re-run with --allow-replace "
f" to confirm:\n {cmd}"
)
def _do_deref(by_id: dict, seeds: set, mapping: dict) -> None:
"""Invert each dereferenceable edge: drop it from the promoted draft and record the
draft's new counter on the referenced draft, restoring the link when that draft is
later promoted. Mutates _text; writes the referenced (still-draft) files."""
draft_ids, touched = set(by_id), set()
for s in seeds:
d, nid = by_id[s], mapping[s]
for t in (set(d.get("relates_to") or []) & draft_ids) - seeds:
d["_text"] = remove_from_list_field(d["_text"], "relates_to", t)
by_id[t]["_text"] = add_to_list_field(by_id[t]["_text"], "relates_to", nid)
touched.add(t)
if d.get("superseded_by") in draft_ids and d.get("superseded_by") not in seeds:
t = d["superseded_by"]
d["_text"] = set_field(d["_text"], "superseded_by", "") # blank, not null
by_id[t]["_text"] = set_field(by_id[t]["_text"], "supersedes", f'"{nid}"')
touched.add(t)
for t in touched:
by_id[t]["_path"].write_text(by_id[t]["_text"], encoding="utf-8")
def promote(
root: Path, queries: list[str], deref: bool = False, allow_replace: bool = False, input_fn=input
) -> tuple[list[Path] | None, str | None]:
"""Promote draft(s) into decisions/ as the next counters. Refuses with actionable
guidance when the set isn't self-contained; `--deref` inverts front-matter edges so a
draft promotes alone; `--allow-replace` confirms archiving the decisions it supersedes."""
recs = load_records(root)
by_id = {d["id"]: d for d in load_drafts(root)}
decision_ids = {r["id"] for r in recs}
seeds = set()
for q in queries:
src, err = select_draft(root, q, input_fn)
if err:
return None, err
seeds.add(parse_front_matter(src.read_text(encoding="utf-8")).get("id"))
bad = [by_id[i]["_file"] for i in seeds if not TYPE_RE.match(str(by_id[i].get("type") or ""))]
if bad:
return None, f"invalid type in {', '.join(bad)}"
deref_t, block_t, supersedes_dec = set(), set(), set()
for s in seeds:
c = classify_refs(by_id[s], set(by_id), decision_ids, seeds)
deref_t |= c["deref"]
block_t |= c["block"]
supersedes_dec |= c["supersedes_dec"]
deref_t -= block_t
if block_t:
msg = _blocking_message(blocking_closure(by_id, seeds, decision_ids), seeds, block_t)
if deref:
msg = (
f"--deref can't proceed — blocked by prose/`supersedes` refs to "
f"{', '.join(sorted(block_t))}.\n" + msg
)
return None, msg
if supersedes_dec and not allow_replace:
return None, _allow_replace_message(supersedes_dec, seeds)
if deref_t and not deref:
return None, _deref_message(deref_t, seeds)
base = int(next_counter(recs))
mapping = {did: f"{base + i:04d}" for i, did in enumerate(sorted(seeds))}
if deref:
_do_deref(by_id, seeds, mapping)
dests = []
for did, nid in mapping.items():
d = by_id[did]
slug = re.sub(r"^[A-Z]{4}-", "", d["_path"].stem)
dest = accepted_dir(root) / d["type"] / f"{nid}-{slug}.md"
text = set_field(d["_text"], "id", f'"{nid}"')
text = set_field(text, "status", "accepted") # the PR proposes; merge accepts
for f in ("change_kind", "author"): # draft-only fields
text = drop_field(text, f)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(text, encoding="utf-8")
d["_path"].unlink()
dests.append(dest)
for old, new in mapping.items(): # repoint refs (intra-set -> counters; inbound too)
rewrite_reference(root, old, new)
for s in seeds: # --allow-replace: archive each decision a promoted draft supersedes
sup = by_id[s].get("supersedes")
if allow_replace and sup in supersedes_dec:
r = next(x for x in recs if x["id"] == sup)
text = set_field(
set_field(r["_text"], "status", "superseded"), "superseded_by", f'"{mapping[s]}"'
)
archived_dir(root).mkdir(parents=True, exist_ok=True)
(archived_dir(root) / r["_file"]).write_text(text, encoding="utf-8")
r["_path"].unlink()
return dests, None
def rename_draft(
root: Path, query: str, new_id: str, input_fn=input
) -> tuple[Path | None, str | None]:
"""Change a draft's 4-letter ID (e.g. a random one -> a mnemonic), renaming the
file and repointing every inbound reference. Counters (decisions) are immutable."""
new = new_id.upper()
if not DRAFT_ID_RE.match(new):
return None, f"{new_id!r} is not a 4-letter ID (A-Z)"
if new in {str(d.get("id", "")).upper() for d in load_drafts(root)}:
return None, f"draft ID {new} is already in use"
src, err = select_draft(root, query, input_fn)
if err:
return None, err
old = parse_front_matter(src.read_text(encoding="utf-8")).get("id")
if old == new:
return None, f"draft already has ID {new}"
slug = re.sub(r"^[A-Z]{4}-", "", src.stem)
dest = src.with_name(f"{new}-{slug}.md")
text = set_field(src.read_text(encoding="utf-8"), "id", new)
src.unlink() # remove first (case-insensitive FS safe)
dest.write_text(text, encoding="utf-8")
rewrite_reference(root, old, new)
return dest, None
def ensure_gitignored(repo: Path, pattern: str) -> None:
"""Add `pattern` to <repo>/.gitignore (creating the file if absent) unless already
listed. The symlinked scripts/decisions.py is a machine-specific relative symlink into
the skill's copy — each clone recreates it via `install`, so it must not be committed."""
gi = repo / ".gitignore"
existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
if pattern in existing.splitlines():
print(f".gitignore already ignores {pattern}")
return
comment = "# decision-records: machine-specific symlink — recreate via `decisions.py install`"
head = existing if not existing or existing.endswith("\n") else existing + "\n"
sep = "\n" if existing.strip() else ""
gi.write_text(head + sep + comment + "\n" + pattern + "\n", encoding="utf-8")
print(f"{'created' if not existing else 'updated'} .gitignore — ignoring {pattern}")
def wire_entry_point(repo: Path, name: str, body: str) -> None:
"""Create a root entry-point file (README.md / AGENTS.md) as a placeholder linking the
scaffold, but only when it's MISSING — a fresh or empty repo. An existing file is left
untouched: the agent adopting the skill wires the link into it instead (see SKILL.md)."""
f = repo / name
if f.exists():
return
f.write_text(body, encoding="utf-8")
print(f"created {name} (placeholder linking the decision records)")
def install(repo: Path) -> None:
"""Set up the convention in a repo: symlink the tool (and gitignore that symlink),
scaffold the docs/ skeleton, wire a root README.md/AGENTS.md when absent, and add a
pre-commit `check` hook in a git repo. `repo` is the project root — install does NOT
search upward; it sets up exactly where you point it (CLI default: the CWD). Idempotent:
only creates what's missing, never overwrites. Run the first time via the skill's own copy
(`python .../decision-records/scripts/decisions.py install`); the symlink works after."""
canonical = Path(__file__).resolve()
skill = canonical.parent.parent # scripts/ -> skill dir
repo = repo.resolve()
scripts = repo / "scripts"
scripts.mkdir(parents=True, exist_ok=True)
link, rel = scripts / "decisions.py", os.path.relpath(canonical, scripts)
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(rel)
print(f"symlinked scripts/decisions.py -> {rel}")
ensure_gitignored(repo, "scripts/decisions.py")
# scaffold docs/decisions/ (idempotent — never overwrites)
docs = repo / "docs"
for d in (accepted_dir(docs), archived_dir(docs), drafts_dir(docs)):
d.mkdir(parents=True, exist_ok=True)
for keep in (accepted_dir(docs) / ".gitkeep", archived_dir(docs) / ".gitkeep"):
keep.exists() or keep.write_text("", encoding="utf-8")
for src, dst in (
(skill / "templates" / "README.md", decisions_dir(docs) / "README.md"),
(skill / "templates" / "AGENTS.md", decisions_dir(docs) / "AGENTS.md"),
(skill / "templates" / "_template.md", decisions_dir(docs) / "_template.md"),
(skill / "templates" / "drafts" / "_template.md", drafts_dir(docs) / "_template.md"),
):
if src.exists() and not dst.exists():
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
print(f"created {dst.relative_to(repo)}")
main(["build"], root=docs) # INDEX.md is GENERATED, not scaffolded — build it
# Wire the root entry points so people/agents discover the scaffold — only when missing
# (a fresh/empty repo); an existing README/AGENTS is the adopter's to wire by hand.
wire_entry_point(
repo,
"README.md",
f"# {repo.name}\n\n"
"<!-- TODO: describe this repo. -->\n\n"
"## Decision records\n\n"
"Significant decisions live under [`docs/decisions/`](docs/decisions/README.md) — "
"browse the [decision index](docs/decisions/INDEX.md).\n",
)
wire_entry_point(
repo,
"AGENTS.md",
f"# AGENTS.md — {repo.name}\n\n"
"<!-- TODO: project-wide agent guidance. -->\n\n"
"## Decision records\n\n"
"Decisions under `docs/decisions/` are **binding** here — read "
"[docs/decisions/AGENTS.md](docs/decisions/AGENTS.md) before changing what they cover.\n",
)
hook, line = (
repo / ".git" / "hooks" / "pre-commit",
"python scripts/decisions.py check || exit 1",
)
if (repo / ".git").is_dir():
existing = hook.read_text(encoding="utf-8") if hook.exists() else ""
if line in existing:
print("pre-commit check already present")
else:
hook.write_text((existing or "#!/usr/bin/env bash\n") + line + "\n", encoding="utf-8")
hook.chmod(0o755)
print("installed pre-commit check")
else:
print("no .git here — add 'python scripts/decisions.py check' to CI")
# ── CLI ─────────────────────────────────────────────────────────────────────
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
raw = list(sys.argv[1:] if argv is None else argv)
p = argparse.ArgumentParser(
prog="decisions.py",
description="Decision-records registry: build the INDEX, validate, rename/promote drafts.",
)
sub = p.add_subparsers(dest="cmd", metavar="{build,check,rename-draft-id,promote,install}")
build = sub.add_parser(
"build", help="write docs/decisions/INDEX.md (optionally relink everything)"
)
build.add_argument(
"--relink",
action="store_true",
help="also refresh generated path links in every docs/*.md (records, drafts, docs)",
)
sub.add_parser("check", help="validate only; exit 1 if INDEX or any link is stale (CI-safe)")
rn = sub.add_parser("rename-draft-id", help="change a draft's ID and repoint all references")
rn.add_argument("query", help="draft ID, title, or filename fragment (fuzzy)")
rn.add_argument("new", help="the new 4-letter ID (UPPERCASE)")
pr = sub.add_parser(
"promote", help="move one or more drafts into decisions/ as the next counters"
)
pr.add_argument(
"query", nargs="+", help="one or more draft IDs/names (space- or comma-separated)"
)
pr.add_argument(
"--deref",
action="store_true",
help="invert dereferenceable front-matter refs so a draft can promote alone",
)
pr.add_argument(
"--allow-replace",
dest="allow_replace",
action="store_true",
help="confirm archiving any decisions this promotion supersedes",
)
ins = sub.add_parser("install", help="symlink the tool into a repo + add a pre-commit check")
ins.add_argument("repo", nargs="?", default=".", help="repo root (default: current dir)")
known = ("build", "check", "rename-draft-id", "promote", "install", "-h", "--help")
if not raw or raw[0] not in known:
raw = ["build"] + raw
return p.parse_args(raw)
def main(argv: list[str] | None = None, root: Path | None = None) -> int:
args = parse_args(argv)
if args.cmd == "install":
install(Path(args.repo))
return 0
root = root or find_docs()
index = index_path(root)
if args.cmd == "rename-draft-id":
dest, err = rename_draft(root, args.query, args.new)
if err:
print(err, file=sys.stderr)
return 1
print(f"renamed -> {dest.relative_to(root)}")
main(["build", "--relink"], root=root)
return 0
if args.cmd == "promote":
queries = [tok for q in args.query for tok in re.split(r"[,\s]+", q) if tok]
dests, err = promote(root, queries, deref=args.deref, allow_replace=args.allow_replace)
if err:
print(err, file=sys.stderr)
return 1
for d in dests:
print(f"promoted -> {d.relative_to(root)}")
main(["build", "--relink"], root=root)
print("finalize each record's front-matter (summary, supersedes/relates_to) in the PR")
return 0
recs = load_records(root)
drafts = load_drafts(root)
refs = ref_map(recs, drafts)
for w in warn_unknown_types(root, drafts): # non-blocking: typo / new-type heads-up
print(w, file=sys.stderr)
struct_errs = validate_records(recs, refs)
out = render_index(recs, root)
if args.cmd == "check":
problems = list(struct_errs)
problems += front_matter_errors(recs, drafts)
problems += validate_no_breach(recs, drafts)
problems += check_links(root, recs, drafts, refs)
problems += validate_drafts(root, refs, drafts)
problems += prose_unknown_counters(root, recs, drafts, refs)
current = index.read_text(encoding="utf-8") if index.exists() else ""
if current.strip() != out.strip():
problems.append("INDEX.md is stale — run scripts/decisions.py build")
stale = [
p.name
for p, own in reference_targets(root, recs, drafts)
if relink(p.read_text(encoding="utf-8"), refs, own, p.parent)
!= p.read_text(encoding="utf-8")
]
if stale:
problems.append(
"links stale — run scripts/decisions.py build --relink: " + ", ".join(stale)
)
if problems:
print("\n".join(problems), file=sys.stderr)
return 1
return 0
if struct_errs:
print("\n".join(struct_errs), file=sys.stderr)
return 1
index.write_text(out, encoding="utf-8")
print(f"wrote {index} ({len(recs)} records)")
if getattr(args, "relink", False):
n = 0
for path, own in reference_targets(root, recs, drafts):
text = path.read_text(encoding="utf-8")
new = relink(text, refs, own, path.parent)
if new != text:
path.write_text(new, encoding="utf-8")
print(f"relinked {path.name}")
n += 1
print(f"relinked {n} file(s)")
residual = check_links(root, recs, drafts, refs)
if residual:
print("\n".join(residual), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
templates/_template.md
---
# Decision record — front-matter is machine-readable; keep it accurate.
# Filename: NNNN-kebab-title.md (the counter, then a kebab title — no type in the name).
# Identity = the counter `id` below: permanent, the only thing cross-references use.
# Authoring, promotion, linking, and validation are the decision-records skill's job —
# this template is just the record's shape.
id: "0000" # global counter, quoted to keep the zero-pad
title: Short imperative title
type: architecture # a lowercase slug = the directory this record lives in
status: accepted # accepted | deprecated | superseded
date: YYYY-MM-DD
deciders: [trung]
summary: One sentence describing the decision.
tags: [] # e.g. [access-control, retrieval, orchestrator]
relates_to: [] # related record IDs, e.g. ["0001", "0003"]
supersedes: # ID this replaces, or blank
superseded_by: # ID that replaces this, or blank
---
# {id} — {title}
## Context
What forces are at play? What problem or tension prompted this decision? State the constraints as
they actually were at decision time. Don't sanitize.
## Decision
The decision in one or two sentences, active voice ("We will …"). Binding once `accepted` — course
changes only by supersession.
## Rationale
Why this option over the others, given the context above.
## Alternatives considered
| Option | Why not |
| :----- | :------ |
| … | … |
## Consequences
**Positive**
- …
**Negative / accepted costs**
- …
**Risks & open questions**
- …
## References
- Threads, specs, external sources, and related records by ID (e.g. `0001`).
templates/AGENTS.md
# AGENTS.md — decision records
This file governs how an agent works with the **decision records** under `docs/decisions/`. These
records are **binding** for work in this repo.
The records live here; **how** to author, promote, link, and validate them is owned by the
**decision-records skill** (the `scripts/decisions.py` tool installed from it). The skill is
authoritative — when this file and the skill disagree, the skill wins. If the tool is missing,
install it before changing any record.
## Read order (every task)
1. This file.
1. `docs/decisions/INDEX.md` — the generated registry. Read it first, then load only the records
whose `type`/`tags` match the task. **Skip `archived/` by default** — consult it only when
tracing why a decision changed.
1. The specific record(s); their front-matter is authoritative.
## Decision governance — highest priority
1. **Review the relevant accepted records before acting** on anything they cover.
1. **If a request conflicts with an accepted record, STOP and surface it** — do not quietly proceed.
Only flag real conflicts; stay silent when aligned.
1. **Never rewrite an accepted decision to change course — supersede it** with a new record that
replaces the old. Refer to records by their counter (e.g. 0001), never by a hand-written path.
## Boundaries
- `accepted/<type>/` and `archived/` — the decision record. **Held firm once accepted**: changing
course requires supersession, never a rewrite; decider-approved maintenance edits (clarity,
staleness, cross-record consistency, typography) are allowed. Firm, not frozen.
- `drafts/` — work-in-progress candidates; mutable until promoted. Promotion is a finalizing step
that needs explicit human sign-off, so never promote on your own initiative.
- Other `docs/*.md` — living docs that may cross-reference records by ID.
- `docs/decisions/INDEX.md` is **generated** — never hand-edit it; regenerate with
`scripts/decisions.py build`.
## Working style
Be precise. Cross-reference before producing, and verify claims against the source-of-truth record,
not a summary. Keep the active decision set clean: one accepted record per decision, retired ones in
`archived/`.
templates/drafts/_template.md
---
# Draft — a decision candidate (WIP). Same shape as a decision record, so promotion is
# mechanical; it just carries a 4-letter ID and status: draft, and lives in drafts/.
# Filename: AAAA-kebab-title.md (AAAA = the 4 UPPERCASE letters of `id` below).
# Promotion, linking, and validation are the decision-records skill's job.
id: ABCD # REQUIRED: 4 UPPERCASE letters, a mnemonic of the topic
title: Short imperative title
type: architecture # a lowercase slug = the directory it lands in on promotion
status: draft # draft | under-review
date: YYYY-MM-DD
deciders: [trung]
summary: One sentence describing the decision.
tags: [] # e.g. [access-control, retrieval, orchestrator]
relates_to: [] # related record IDs — counters and/or draft IDs
supersedes: # a decision this replaces, or blank
superseded_by: # ID that replaces this, or blank
---
# {id} — {title}
## Context
What forces are at play? What problem or tension prompts this decision? State the constraints as
they actually are. Don't sanitize.
## Decision
The proposed decision in one or two sentences, active voice ("We will …"). Binding once promoted and
`accepted` — course changes only by supersession.
## Rationale
Why this option over the others, given the context above.
## Alternatives considered
| Option | Why not |
| :----- | :------ |
| … | … |
## Consequences
**Positive**
- …
**Negative / accepted costs**
- …
**Risks & open questions**
- …
## References
- Threads, specs, external sources, and related records by ID (e.g. `0001`, `TIER`).
templates/README.md
# Decision records
This repo records significant decisions as short, numbered documents (ADR-style), held firm once
accepted. Everything lives under `docs/decisions/`, so nothing here is confused with the repo's
other docs.
## Working with an agent (the easy way)
If you use an agent that has the **decision-records** skill installed, just ask — it knows the
lifecycle, mints IDs, runs the tool, and keeps the index and links correct. For example:
- "Capture what we just discussed into a decision draft."
- "Draft an architecture decision: we'll use X for Y."
- "Promote that draft." / "Promote CONF."
- "Supersede our caching decision with a new one that does Z instead."
- "What have we decided about authentication?"
- "Regenerate the decision index."
Promoting and superseding finalize a record, so the agent confirms with you first.
## The lifecycle
A decision moves through three stages, one directory each:
| Stage | Directory | Identified by | Meaning |
| :------- | :----------------- | :------------------------------------ | :-------------------------------------------- |
| Draft | `drafts/` | a 4-uppercase-letter mnemonic (CONF) | a work-in-progress candidate; edit freely |
| Accepted | `accepted/<type>/` | a zero-padded counter (0001, 0002, …) | a finalized decision, **held firm** |
| Retired | `archived/` | (keeps its counter) | superseded or deprecated; kept for the record |
There is no "proposed" stage — *proposing* a decision is the act of opening a pull request that
promotes a draft. Once a draft lands in `accepted/` it is held firm: changing course means
superseding it with a newer decision, never rewriting it; decider-approved maintenance edits
(clarity, staleness, consistency) are allowed. `<type>` is an open lowercase label you choose
(architecture, product, security, policy, legal, …); each type is its own subdirectory under
`accepted/`.
## Reading the records
Start at [INDEX.md](./INDEX.md) — a generated table of every accepted and retired decision with its
type, summary, status, and tags. Open the records you need; skip `archived/` unless you're tracing
why a decision changed.
## Authoring & cross-referencing
- Copy `_template.md` for a decision, or `drafts/_template.md` for a draft, and fill it in.
- Mint a draft's 4-letter ID yourself (a mnemonic of the topic). Accepted records get the next
global counter automatically when a draft is promoted.
- Refer to another record by writing its ID inline — the tooling renders IDs as links and keeps them
correct when files move. Never hand-write a path to a record.
## Promotion & superseding
Promotion turns a draft into the next accepted decision; it is a finalizing step that needs human
sign-off. To replace an existing decision, set the draft's `supersedes` front-matter to that
decision's counter. On promotion (confirmed with `--allow-replace`) the tool **automatically retires
the superseded record for you** — it moves the old record to `archived/`, flips its status to
`superseded`, and records the new counter in the old record's `superseded_by`. You never hand-edit
the record being replaced.
## The tooling
`scripts/decisions.py` generates the index and cross-links, validates the tree, and promotes drafts:
```sh
python scripts/decisions.py build [--relink] # regenerate INDEX.md (+ refresh links)
python scripts/decisions.py check # validate (CI-safe; exit 1 if stale)
python scripts/decisions.py promote <name…> [--deref] [--allow-replace] # draft(s) -> accepted/
python scripts/decisions.py rename-draft-id <name> <NEW> # re-ID a draft
python scripts/decisions.py install [repo] # adopt in a repo: symlink + pre-commit
```
Run `python scripts/decisions.py --help` for the rest. This convention is provided by the
**decision-records** skill, which is the source of truth for how the tooling behaves; this file is a
local orientation guide.
tests/conftest.py
import sys
from pathlib import Path
# Tests live in the skill; the tool is in the sibling scripts/ dir. Put that on the path
# so `import decisions` loads the tool directly — no symlink needed.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
tests/test_decisions.py
"""
Tests for scripts/decisions.py — the decision-records registry tool.
Option B: the tool takes an injectable `root` (the docs/ dir), so every test runs
against a synthetic tree under tmp_path and never touches the real repo.
"""
import decisions
import pytest
# ── fixture helpers ─────────────────────────────────────────────────────────
def record_text(
counter,
typ,
title,
*,
body="",
status="accepted",
relates_to="[]",
supersedes="null",
superseded_by="null",
summary="one-line summary",
tags="[]",
):
return (
f'---\nid: "{counter}"\ntitle: {title}\ntype: {typ}\nstatus: {status}\n'
f"summary: {summary}\ntags: {tags}\nrelates_to: {relates_to}\n"
f"supersedes: {supersedes}\nsuperseded_by: {superseded_by}\n---\n\n"
f"# {counter} — {title}\n\n{body}\n"
)
def place(root, counter, typ, title, *, lifecycle="decisions", subdir=None, **kw):
d = (
root / "decisions" / "accepted" / (subdir or typ)
if lifecycle == "decisions"
else root / "decisions" / lifecycle
)
d.mkdir(parents=True, exist_ok=True)
p = d / f"{counter}-{title}.md"
p.write_text(record_text(counter, typ, title, **kw), encoding="utf-8")
return p
def place_draft(
root,
did,
typ,
title,
*,
status="draft",
relates_to="[]",
supersedes="null",
superseded_by="null",
body="",
):
d = root / "decisions" / "drafts"
d.mkdir(parents=True, exist_ok=True)
p = d / f"{did}-{title}.md"
p.write_text(
f"---\nid: {did}\ntitle: {title}\ntype: {typ}\nstatus: {status}\n"
f"relates_to: {relates_to}\nsupersedes: {supersedes}\nsuperseded_by: {superseded_by}\n"
f"---\n\n# {did} — {title}\n\n{body}\n",
encoding="utf-8",
)
return p
def write_threat_model(root, body):
root.mkdir(parents=True, exist_ok=True)
(root / "threat-model.md").write_text(f"# Threat Model\n\n{body}\n", encoding="utf-8")
@pytest.fixture
def root(tmp_path):
"""docs/ with contiguous accepted 0001..0003; 0003 (security) cites 0001 & 0002."""
docs = tmp_path / "docs"
place(docs, "0001", "architecture", "alpha")
place(docs, "0002", "architecture", "beta")
place(docs, "0003", "security", "gamma", body="builds on `0001` and `0002`.")
return docs
@pytest.fixture
def built(root):
assert decisions.main(["build", "--relink"], root=root) == 0
return root
# ── front-matter parsing: block scalars ─────────────────────────────────────
def test_folded_scalar_summary_joins_lines():
text = (
'---\nid: "0001"\nsummary: >-\n A summary written as a folded block scalar\n'
" that continues across several indented lines.\nstatus: accepted\n---\n\nbody\n"
)
fm = decisions.parse_front_matter(text)
assert fm["summary"] == (
"A summary written as a folded block scalar that continues across several indented lines."
)
assert fm["status"] == "accepted" # the field after the block is still parsed
def test_literal_scalar_summary_preserves_newlines():
text = '---\nid: "0001"\nsummary: |\n line one\n line two\nstatus: accepted\n---\n\nbody\n'
fm = decisions.parse_front_matter(text)
assert fm["summary"] == "line one\nline two"
def test_block_scalar_with_no_continuation_is_empty():
text = '---\nid: "0001"\nsummary: >-\nstatus: accepted\n---\n\nbody\n'
fm = decisions.parse_front_matter(text)
assert fm["summary"] == ""
assert fm["status"] == "accepted"
def test_folded_summary_renders_in_index(root):
p = root / "decisions/accepted/architecture/0001-alpha.md"
p.write_text(
p.read_text().replace(
"summary: one-line summary",
"summary: >-\n A summary written as a folded block scalar\n across two lines.",
)
)
assert decisions.main(["build"], root=root) == 0
index = (root / "decisions" / "INDEX.md").read_text()
assert "A summary written as a folded block scalar across two lines." in index
assert ">-" not in index
def test_check_flags_bare_scalar_indicator_summary(root):
p = root / "decisions/accepted/architecture/0001-alpha.md"
p.write_text(p.read_text().replace("summary: one-line summary", 'summary: ">-"'))
assert decisions.main(["check"], root=root) == 1
# ── build / render ──────────────────────────────────────────────────────────
def test_build_writes_index_sorted(root):
assert decisions.main(["build"], root=root) == 0
index = (root / "decisions" / "INDEX.md").read_text()
assert index.index("0001") < index.index("0002") < index.index("0003")
assert "(accepted/architecture/0001-alpha.md)" in index
assert "| architecture |" in index and "| security |" in index
def test_index_empty_when_no_decisions(tmp_path):
docs = tmp_path / "docs"
place_draft(docs, "ABCD", "security", "wip")
assert decisions.main(["build"], root=docs) == 0
assert "no decisions yet" in (docs / "decisions" / "INDEX.md").read_text()
def test_no_proposed_status_allowed(root):
place(root, "0004", "architecture", "wip", status="proposed")
assert decisions.main(["check"], root=root) == 1
# ── relink ──────────────────────────────────────────────────────────────────
def test_relink_cross_directory(built):
body = (built / "decisions/accepted/security/0003-gamma.md").read_text()
assert "[`0001`](../architecture/0001-alpha.md)" in body
def test_relink_idempotent(built):
p = built / "decisions/accepted/security/0003-gamma.md"
before = p.read_text()
assert decisions.main(["build", "--relink"], root=built) == 0
assert p.read_text() == before
def test_move_self_heals(built):
src = built / "decisions/accepted/architecture/0001-alpha.md"
src.rename(src.with_name("0001-alpha-renamed.md"))
assert decisions.main(["check"], root=built) == 1
assert decisions.main(["build", "--relink"], root=built) == 0
assert decisions.main(["check"], root=built) == 0
gamma = (built / "decisions/accepted/security/0003-gamma.md").read_text()
assert "0001-alpha-renamed.md" in gamma
# ── drafts: UPPERCASE IDs, draft↔draft links ────────────────────────────────
def test_draft_to_draft_link(root):
place_draft(root, "AAAA", "security", "one")
place_draft(root, "BBBB", "architecture", "two", body="relates to `AAAA`.")
assert decisions.main(["build", "--relink"], root=root) == 0
assert "[`AAAA`](AAAA-one.md)" in (root / "decisions/drafts/BBBB-two.md").read_text()
def test_lowercase_draft_id_rejected(built):
place_draft(built, "abcd", "security", "lower")
assert decisions.main(["check"], root=built) == 1
def test_draft_requires_4letter_id(built):
place_draft(built, "AB", "security", "shorty")
assert decisions.main(["check"], root=built) == 1
def test_draft_duplicate_id(built):
place_draft(built, "ABCD", "security", "one")
place_draft(built, "ABCD", "architecture", "two")
assert decisions.main(["check"], root=built) == 1
# ── threat-model.md in the link system ──────────────────────────────────────
def test_threat_model_gets_linked(built):
write_threat_model(built, "Vector X addressed by `0001`.")
assert decisions.main(["build", "--relink"], root=built) == 0
link = "[`0001`](decisions/accepted/architecture/0001-alpha.md)"
assert link in (built / "threat-model.md").read_text()
assert decisions.main(["check"], root=built) == 0
def test_threat_model_broken_ref_flagged(built):
write_threat_model(built, "Addressed by `0099`.")
assert decisions.main(["check"], root=built) == 1
def test_any_doc_is_linked_and_checked(built):
(built / "roadmap.md").write_text(
"# Roadmap\n\nMilestone builds on `0001`.\n", encoding="utf-8"
)
assert decisions.main(["build", "--relink"], root=built) == 0
link = "[`0001`](decisions/accepted/architecture/0001-alpha.md)"
assert link in (built / "roadmap.md").read_text()
(built / "roadmap.md").write_text("# Roadmap\n\nBuilds on `0099`.\n", encoding="utf-8")
assert decisions.main(["check"], root=built) == 1
def test_templates_and_index_excluded_from_linkcheck(built):
(built / "_scratch.md").write_text(
"# scratch\n\nrefs `0099`.\n", encoding="utf-8"
) # _-prefixed
assert decisions.main(["check"], root=built) == 0 # excluded, so its bad ref is ignored
# ── placement, duplicates, skips ────────────────────────────────────────────
def test_dir_must_match_type(root):
place(root, "0004", "architecture", "misplaced", subdir="security")
assert decisions.main(["check"], root=root) == 1
def test_custom_type_is_open(built):
place_draft(built, "LEGL", "legal", "retention-policy") # a type outside the usual set
assert decisions.main(["promote", "LEGL"], root=built) == 0
# the type's dir is auto-created on promotion
assert (built / "decisions/accepted/legal/0004-retention-policy.md").exists()
assert decisions.main(["check"], root=built) == 0
def test_invalid_type_slug_rejected(built):
place_draft(built, "ABCD", "Security", "bad-slug") # uppercase -> not a slug
assert decisions.main(["check"], root=built) == 1
def test_new_type_warns_but_passes(built, capsys):
place_draft(built, "LEGL", "legal", "thing") # no decisions/legal/ yet
assert decisions.main(["check"], root=built) == 0 # non-blocking
assert "new type" in capsys.readouterr().err # but warned
def test_archived_status_must_be_retired(root):
place(root, "0004", "architecture", "notretired", lifecycle="archived", status="accepted")
assert decisions.main(["check"], root=root) == 1
def test_superseded_in_archived_ok(built):
place(built, "0004", "architecture", "retired", lifecycle="archived", status="superseded")
assert decisions.main(["build", "--relink"], root=built) == 0
assert decisions.main(["check"], root=built) == 0
def test_duplicate_counter(root):
place(root, "0001", "security", "clash")
assert decisions.main(["check"], root=root) == 1
def test_gap_in_counters(root):
place(root, "0005", "architecture", "skipper")
assert decisions.main(["check"], root=root) == 1
# ── decision → draft breach ─────────────────────────────────────────────────
def test_decision_referencing_draft_is_breach(built):
place(built, "0004", "architecture", "leaky", body="depends on `WXYZ`.")
place_draft(built, "WXYZ", "security", "candidate")
assert decisions.main(["check"], root=built) == 1
def test_decision_relates_to_draft_is_breach(built):
place(built, "0004", "architecture", "leaky2", relates_to='["WXYZ"]')
place_draft(built, "WXYZ", "security", "candidate")
assert decisions.main(["check"], root=built) == 1
def test_decision_linked_draft_ref_is_breach(built):
# After relink a body ref becomes a markdown LINK; it must STILL be a breach, not only
# bare inline-code refs (regression guard for the linked-ref blind spot).
place_draft(built, "WXYZ", "security", "candidate")
place(built, "0004", "architecture", "leaky3", body="builds on `WXYZ`.")
assert decisions.main(["build", "--relink"], root=built) == 0 # refresh INDEX + linkify the ref
body = (built / "decisions/accepted/architecture/0004-leaky3.md").read_text()
assert "[`WXYZ`](" in body # ref is now a markdown link
assert decisions.main(["check"], root=built) == 1 # ...and is still flagged
# ── rename-draft-id ─────────────────────────────────────────────────────────
def test_rename_draft_repoints_refs(built):
place_draft(built, "ABCD", "security", "target")
place_draft(
built, "EFGH", "architecture", "referrer", relates_to='["ABCD"]', body="depends on `ABCD`."
)
assert decisions.main(["rename-draft-id", "ABCD", "ZZZZ"], root=built) == 0
ref = (built / "decisions/drafts/EFGH-referrer.md").read_text()
assert "ZZZZ" in ref and "ABCD" not in ref
assert decisions.main(["check"], root=built) == 0
def test_rename_draft_auto_uppercases(built):
place_draft(built, "ABCD", "security", "thing")
dest, err = decisions.rename_draft(built, "ABCD", "wxyz")
assert err is None and dest.name == "WXYZ-thing.md"
def test_rename_draft_rejects_conflict(built):
place_draft(built, "AAAA", "security", "one")
place_draft(built, "BBBB", "architecture", "two")
assert decisions.main(["rename-draft-id", "AAAA", "BBBB"], root=built) == 1
def test_rename_draft_rejects_invalid_id(built):
place_draft(built, "AAAA", "security", "one")
dest, err = decisions.rename_draft(built, "AAAA", "AB12")
assert dest is None and err
# ── promote: single, closure/breach, multi, cycle ───────────────────────────
def test_promote_assigns_next_counter_accepted(built):
place_draft(built, "QWER", "security", "new-idea")
assert decisions.main(["promote", "QWER"], root=built) == 0
dest = built / "decisions/accepted/security/0004-new-idea.md"
assert dest.exists()
assert 'id: "0004"' in dest.read_text() and "status: accepted" in dest.read_text()
assert not (built / "decisions/drafts/QWER-new-idea.md").exists()
assert decisions.main(["check"], root=built) == 0
def test_promote_rewrites_inbound_refs(built):
place_draft(built, "ABCD", "security", "target")
place_draft(
built, "EFGH", "architecture", "referrer", relates_to='["ABCD"]', body="depends on `ABCD`."
)
assert decisions.main(["promote", "ABCD"], root=built) == 0
ref = (built / "decisions/drafts/EFGH-referrer.md").read_text()
assert "`0004`" in ref and "ABCD" not in ref
assert decisions.main(["check"], root=built) == 0
def test_promote_breach_refused_with_closure(built):
place_draft(built, "ABCD", "security", "needs-dep", body="see `EFGH`.")
place_draft(built, "EFGH", "architecture", "dep")
# promoting ABCD alone would leave a decision pointing at draft EFGH -> refused
dests, err = decisions.promote(built, ["ABCD"])
assert dests is None and err
assert "EFGH" in err and "ABCD" in err and "requested" in err
assert decisions.main(["promote", "ABCD"], root=built) == 1
def test_promote_closed_set_succeeds(built):
place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
place_draft(built, "EFGH", "architecture", "b")
assert decisions.main(["promote", "ABCD", "EFGH"], root=built) == 0
assert not list((built / "decisions" / "drafts").glob("[A-Z]*.md")) # both consumed
assert decisions.main(["check"], root=built) == 0 # no decision -> draft
def test_promote_cycle_needs_both(built):
place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
place_draft(built, "EFGH", "architecture", "b", body="see `ABCD`.")
assert decisions.main(["promote", "ABCD"], root=built) == 1 # cycle -> breach
assert decisions.main(["promote", "ABCD", "EFGH"], root=built) == 0 # together works
assert decisions.main(["check"], root=built) == 0
def test_promote_comma_separated(built):
place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
place_draft(built, "EFGH", "architecture", "b")
assert decisions.main(["promote", "ABCD,EFGH"], root=built) == 0
assert decisions.main(["check"], root=built) == 0
def test_promote_disambiguates(built):
place_draft(built, "QWER", "architecture", "alpha-cand")
place_draft(built, "QWES", "security", "beta-cand")
dests, err = decisions.promote(built, ["qwe"], input_fn=lambda _: "1")
assert err is None and dests[0].name == "0004-alpha-cand.md"
def test_promote_no_match(built):
assert decisions.main(["promote", "ZZZZ"], root=built) == 1
# ── deref / blocking / replace ──────────────────────────────────────────────
def test_promote_deref_inverts_frontmatter_edge(built):
place_draft(built, "AAAA", "architecture", "alpha", relates_to='["BBBB"]')
place_draft(built, "BBBB", "security", "beta")
dests, err = decisions.promote(built, ["AAAA"]) # front-matter-only ref
assert dests is None and "--deref" in err
assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 0
assert (built / "decisions/accepted/architecture/0004-alpha.md").exists()
assert '"0004"' in (built / "decisions/drafts/BBBB-beta.md").read_text() # link moved onto BBBB
assert decisions.main(["check"], root=built) == 0
def test_promote_deref_inverts_superseded_by_edge(built):
# superseded_by is a scalar edge: inverting it must set BBBB's *single* `supersedes`
# counter, not coerce it into a list (which would crash check on the unhashable value).
place_draft(built, "AAAA", "architecture", "alpha", superseded_by='"BBBB"')
place_draft(built, "BBBB", "security", "beta")
dests, err = decisions.promote(built, ["AAAA"]) # front-matter-only ref
assert dests is None and "--deref" in err
assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 0
assert (built / "decisions/accepted/architecture/0004-alpha.md").exists()
bbbb = decisions.parse_front_matter((built / "decisions/drafts/BBBB-beta.md").read_text())
assert bbbb["supersedes"] == "0004" # scalar counter, not a list
assert decisions.main(["check"], root=built) == 0
def test_prose_ref_blocks_and_beats_deref(built):
place_draft(built, "AAAA", "architecture", "alpha", relates_to='["BBBB"]', body="see `BBBB`.")
place_draft(built, "BBBB", "security", "beta")
dests, err = decisions.promote(built, ["AAAA"])
assert dests is None and "blocked" in err and "--deref" not in err # blocking wins
def test_deref_rejected_when_blocking(built):
place_draft(built, "AAAA", "architecture", "alpha", body="see `BBBB`.")
place_draft(built, "BBBB", "security", "beta")
assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 1
def test_supersedes_decision_needs_replace_then_archives(built):
place_draft(built, "CCCC", "architecture", "newer", supersedes='"0001"')
assert decisions.main(["promote", "CCCC"], root=built) == 1 # needs --allow-replace
assert decisions.main(["promote", "CCCC", "--allow-replace"], root=built) == 0
archived = built / "decisions/archived/0001-alpha.md"
assert archived.exists() and "superseded" in archived.read_text()
assert decisions.main(["check"], root=built) == 0
# ── check semantics & CLI ───────────────────────────────────────────────────
def test_check_clean(built):
assert decisions.main(["check"], root=built) == 0
def test_check_read_only(built):
p = built / "decisions/accepted/security/0003-gamma.md"
before = (p.read_text(), (built / "decisions" / "INDEX.md").read_text())
assert decisions.main(["check"], root=built) == 0
assert (p.read_text(), (built / "decisions" / "INDEX.md").read_text()) == before
def test_check_detects_stale_index(built):
p = built / "decisions/accepted/architecture/0001-alpha.md"
p.write_text(p.read_text().replace("one-line summary", "changed"))
assert decisions.main(["check"], root=built) == 1
def test_bare_relink_folds_into_build(root):
assert decisions.main(["--relink"], root=root) == 0
assert "[`0001`]" in (root / "decisions/accepted/security/0003-gamma.md").read_text()
def test_unknown_subcommand_exits_2(root):
with pytest.raises(SystemExit) as e:
decisions.main(["bogus"], root=root)
assert e.value.code == 2
def test_relink_not_valid_on_check(root):
with pytest.raises(SystemExit) as e:
decisions.main(["check", "--relink"], root=root)
assert e.value.code == 2
def test_promote_requires_query(root):
with pytest.raises(SystemExit) as e:
decisions.main(["promote"], root=root)
assert e.value.code == 2
def test_rename_requires_two_args(root):
with pytest.raises(SystemExit) as e:
decisions.main(["rename-draft-id", "ABCD"], root=root)
assert e.value.code == 2
def test_install_scaffolds_fresh_repo(tmp_path):
decisions.install(tmp_path) # repo root = tmp_path (no upward search)
docs = tmp_path / "docs"
assert (tmp_path / "scripts" / "decisions.py").is_symlink()
for p in (
"decisions/accepted/.gitkeep",
"decisions/archived/.gitkeep",
"decisions/README.md",
"decisions/AGENTS.md",
"decisions/_template.md",
"decisions/drafts/_template.md",
"decisions/INDEX.md",
):
assert (docs / p).exists(), p
assert decisions.main(["check"], root=docs) == 0 # scaffolded repo is valid immediately
# gitignores the machine-specific symlink (creating .gitignore in an empty repo)
assert "scripts/decisions.py" in (tmp_path / ".gitignore").read_text().splitlines()
# wires root entry points (absent in a fresh repo) with placeholders linking the scaffold
readme, agents = (tmp_path / "README.md").read_text(), (tmp_path / "AGENTS.md").read_text()
assert "docs/decisions/README.md" in readme and "docs/decisions/INDEX.md" in readme
assert "docs/decisions/AGENTS.md" in agents
decisions.install(tmp_path) # idempotent re-run doesn't raise
def test_install_preserves_existing_entry_points(tmp_path):
(tmp_path / "README.md").write_text("# Existing\n")
(tmp_path / "AGENTS.md").write_text("# Existing agents\n")
(tmp_path / ".gitignore").write_text("node_modules/\n")
decisions.install(tmp_path)
assert (tmp_path / "README.md").read_text() == "# Existing\n" # never overwritten
assert (tmp_path / "AGENTS.md").read_text() == "# Existing agents\n"
gi = (tmp_path / ".gitignore").read_text().splitlines()
assert "node_modules/" in gi and "scripts/decisions.py" in gi # appended, not clobbered
decisions.install(tmp_path) # idempotent: a second run adds no duplicate ignore line
assert (tmp_path / ".gitignore").read_text().count("scripts/decisions.py") == 1
# ── link check: code spans and anchors ──────────────────────────────────────
def test_heading_anchors_github_slugs():
text = (
"# Appendix — Harnesses evaluated (research notes, non-binding)\n\n"
'## "dreaming"\n\n## Risks & open questions\n\n## dup\n\n## dup\n\n'
"```\n## not a heading\n```\n"
)
assert decisions.heading_anchors(text) == {
"appendix--harnesses-evaluated-research-notes-non-binding",
"dreaming",
"risks--open-questions",
"dup",
"dup-1",
}
def test_link_inside_code_span_is_not_checked(built):
p = built / "decisions/accepted/architecture/0001-alpha.md"
p.write_text(p.read_text() + "\nUse the form `[term](../../glossary.md#term)` when linking.\n")
assert decisions.main(["check"], root=built) == 0
def test_link_inside_fenced_block_is_not_checked(built):
p = built / "decisions/accepted/architecture/0001-alpha.md"
p.write_text(p.read_text() + "\n```md\n[term](../../nowhere.md#x)\n```\n")
assert decisions.main(["check"], root=built) == 0
def test_broken_cross_file_anchor_is_flagged(built, capsys):
(built / "glossary.md").write_text("# Glossary\n\n## sandbox\n\nbody\n")
p = built / "decisions/accepted/architecture/0001-alpha.md"
body = p.read_text()
p.write_text(body + "\nsee [sandbox](../../../glossary.md#sandbox).\n")
assert decisions.main(["check"], root=built) == 0
p.write_text(body + "\nsee [sandbox](../../../glossary.md#sandbxo).\n")
assert decisions.main(["check"], root=built) == 1
assert "broken anchor" in capsys.readouterr().err
def test_intra_doc_anchor_is_validated(built, capsys):
p = built / "decisions/accepted/architecture/0001-alpha.md"
body = p.read_text() + "\n## Context\n\nsee [Context](#context).\n"
p.write_text(body)
assert decisions.main(["check"], root=built) == 0
p.write_text(body.replace("(#context)", "(#contxt)"))
assert decisions.main(["check"], root=built) == 1
assert "broken anchor" in capsys.readouterr().err
# ── front matter: strict-YAML hazards ───────────────────────────────────────
def test_unquoted_colon_in_summary_fails_check(root, capsys):
place(root, "0004", "product", "delta", summary="Two modes: channel and principal")
assert decisions.main(["check"], root=root) == 1
assert "unquoted ':'" in capsys.readouterr().err
def test_quoted_colon_in_summary_passes_check(root):
place(root, "0004", "product", "delta", summary='"Two modes: channel and principal"')
assert decisions.main(["build", "--relink"], root=root) == 0
assert decisions.main(["check"], root=root) == 0
def test_block_scalar_colon_passes_check(root):
place(root, "0004", "product", "delta", summary=">-\n Two modes: channel and principal")
assert decisions.main(["build", "--relink"], root=root) == 0
assert decisions.main(["check"], root=root) == 0
def test_unbalanced_quote_fails_check(root, capsys):
place(root, "0004", "product", "delta", summary='"half open')
assert decisions.main(["check"], root=root) == 1
assert "unbalanced quote" in capsys.readouterr().err