docs(520,521): fold Fable review — body-diff guard, two-PR split, key/backstop fixes

Refs #520 #521

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 02:58:58 +02:00
co-authored by Claude Opus 4.8
parent 0637fa46ac
commit 404ec95479
2 changed files with 343 additions and 132 deletions
@@ -22,9 +22,26 @@ Actions (`.gitea/workflows/docker-build.yml`), Husky hooks.
- Work in the worktree `feat/520-521-decision-lifecycle` off `origin/main`. Never commit in
`/Users/timothy/ersatztv`.
- This arc rewrites `docs/decisions.md` wholesale; land it as ONE PR, rebase-then-
`merge_when_checks_succeed`. On rebase conflicts in generated artifacts (`docs/decisions/README.md`),
REGENERATE — never hand-merge.
- **Two PRs, one arc** (Fable review #6; matches #521's "bounded topic batches, avoid a single
conflict-heavy rewrite"):
- **PR1 — decision-lifecycle machinery** (Tasks 1, 2, 3, 5, 7): parser, validator, catalog
generator, exemplar reconciliations (incl. dogfooding the append-only→lifecycle supersession),
guard swap, `decisions.md` header rewrite. CI wires the **decisions** validator + catalog `--check`
only. Small, low-conflict diff. The validator tolerates `legacy-unmigrated`, so concurrent sessions
appending old-style entries stay green. Branch `feat/521-decision-lifecycle-machinery` off
`origin/main`.
- **PR2 — migration + startup** (Tasks 4, 6, 8, 9): kickoff regression guard + its CI wiring, full
corpus migration, #520 kickoff rewrite, retrieval-eval. **The kickoff guard lives here, not in PR1**
— it fails on the pre-rewrite docs (they still say "read #237"), so it can only be wired into CI in
the same PR that cleans the language (Task 8). Branched off PR1's merged head. Migration conflicts
are cheap (a concurrently appended unmigrated entry just gets a metadata block on rebase).
- Each PR: rebase-then-`merge_when_checks_succeed`. On rebase conflicts in generated artifacts
(`docs/decisions/README.md`), REGENERATE — never hand-merge.
- **`[decisions-edit]` is kept narrow, not retired** (Fable review #3/#4): lifecycle *metadata* writes
(add record, set `superseded-by`, move to archive, regen catalog) are token-free — the validator
proves them well-formed. A change to a record's *rationale prose* still requires `[decisions-edit]`
in the commit range, enforced by the validator's body-diff (Task 2). This closes the silent-rewrite
hole while removing friction from routine lifecycle writes.
- Metadata block format (verbatim; the parser depends on it), placed immediately under each `##`
heading as the first non-blank lines:
```
@@ -239,7 +256,11 @@ def parse_text(text: str, source: Path) -> list[Record]:
rec.since = meta.get("since") or None
rec.supersedes = meta.get("supersedes") or None
rec.superseded_by = meta.get("superseded-by") or None
# The metadata block is contiguous: scan only until the first blank line, so a
# bolded **Rule:** appearing later inside rationale prose can't overwrite the real one.
for bl2 in body_lines[k + 1 :]:
if not bl2.strip():
break
if bl2.startswith("**Rule:**"):
rec.rule = bl2[len("**Rule:**") :].strip()
elif bl2.startswith("**Signals:**"):
@@ -292,11 +313,18 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): decision-record parser (de
- Test: `scripts/tests/test_decisions_validate.py` (+ good/bad fixtures)
**Interfaces:**
- Consumes: `decisions_lib.all_active_records`, `Record`, `KEY_RE`, `STATUSES`, `ARCHIVE_DIR`.
- Produces: `validate(records, archive_records, catalog_ok, budget, removed_active_headings) ->
list[str]` (returns list of error strings; empty = pass); CLI `main()` with
`--base REF --head REF` (no-vanish diff), `--budget N`, exit 1 on any error.
- Consumes: `decisions_lib` (`all_active_records`, `parse_text`, `KEY_RE`, `STATUSES`, `ARCHIVE_DIR`,
`TOPIC_DIR`, `DECISIONS_MD`).
- Produces: `validate(records, *, archive_keys, catalog_ok, budget_ok, removed, rewritten) ->
list[str]` (returns error strings; empty = pass); CLI `main()` with `--base REF --head REF` (enables
the merge-base no-vanish + body-diff checks), `--budget N`, exit 1 on any error.
- Non-decision headings skipped: `{"Index", "Active catalog"}` (configurable `SKIP_HEADINGS`).
- Diff semantics (only when `--base/--head` given): compute `mb = git merge-base BASE HEAD` and diff
`mb → HEAD` (never BASE-tip, which false-flags on every main advance). `removed` = active headings
at `mb` gone at `HEAD` and not present in `archive/` at `HEAD` (across `decisions.md` AND all topic
files). `rewritten` = headings whose *rationale body* (record body minus the metadata/Rule/Signals/
Mechanics lines) changed `mb → HEAD` (for surviving records) OR whose archived copy body ≠ its `mb`
active body — UNLESS `[decisions-edit]` appears in `git log --format=%B mb..HEAD`.
- [ ] **Step 1: Write failing tests** `scripts/tests/test_decisions_validate.py`
@@ -327,34 +355,43 @@ def _rec(**kw):
return dl.Record(**base)
def _v(recs, **kw):
args = dict(archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[], rewritten=[])
args.update(kw)
return dv.validate(recs, **args)
def test_two_active_same_key_fails():
recs = [_rec(key="a.b"), _rec(key="a.b")]
errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
assert any("more than one active" in e for e in errs)
assert any("more than one active" in e for e in _v([_rec(key="a.b"), _rec(key="a.b")]))
def test_bad_key_format_fails():
recs = [_rec(key="BadKey")]
errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
assert any("key format" in e for e in errs)
assert any("key format" in e for e in _v([_rec(key="BadKey")]))
def test_dangling_superseded_by_fails():
recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")]
errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
assert any("superseded-by" in e and "a.c" in e for e in errs)
assert any("superseded-by" in e and "a.c" in e for e in _v(recs))
def test_superseded_by_resolves_to_archive_key_passes():
# successor lives in archive → known via archive_keys, no dangling error
recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")]
assert not any("superseded-by" in e for e in _v(recs, archive_keys={"a.c"}))
def test_removed_active_not_in_archive_fails():
errs = dv.validate([], archive_keys=set(), catalog_ok=True, budget_ok=True,
removed=["2026-01-01 — Gone (#9)"])
assert any("removed from the active set" in e for e in errs)
assert any("removed from the active set" in e
for e in _v([], removed=["2026-01-01 — Gone (#9)"]))
def test_rewritten_rationale_without_token_fails():
assert any("rationale" in e and "decisions-edit" in e
for e in _v([_rec(key="a.b")], rewritten=["2026-01-01 — Reworded (#9)"]))
def test_clean_corpus_passes():
recs = [_rec(key="a.b"), _rec(key="c.d")]
errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
assert errs == []
assert _v([_rec(key="a.b"), _rec(key="c.d")]) == []
```
- [ ] **Step 2: Run to verify fail**
@@ -370,7 +407,9 @@ Expected: FAIL (`ModuleNotFoundError: scripts.decisions_validate`)
Replaces the line-level append-only guard (ersatztv#303 H9) with lifecycle checks that preserve its
spirit — rationale is never silently rewritten or deleted; every history touch is deliberate and
reviewable. Fail-open on tooling trouble (missing refs, parse issues), matching the old guard.
reviewable. `[decisions-edit]` is kept ONLY for rationale-prose edits (see body-diff below); routine
lifecycle metadata writes are token-free. Fail-open on tooling trouble (missing refs, git errors,
parse issues), matching the old guard.
"""
from __future__ import annotations
@@ -383,6 +422,7 @@ import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as m
SKIP_HEADINGS = {"Index", "Active catalog"}
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by")
EDIT_TOKEN = "[decisions-edit]"
def _key_of(ref: str | None) -> str | None:
@@ -391,16 +431,17 @@ def _key_of(ref: str | None) -> str | None:
return ref.split("@", 1)[0].strip()
def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[str]:
def validate(records, *, archive_keys, catalog_ok, budget_ok, removed, rewritten) -> list[str]:
errs: list[str] = []
decision_recs = [r for r in records if r.heading not in SKIP_HEADINGS]
decision_recs = [
r for r in records
if r.heading not in SKIP_HEADINGS and r.status != "legacy-unmigrated"
]
active_by_key: dict[str, int] = {}
known_keys = set(archive_keys)
for r in decision_recs:
if r.status == "legacy-unmigrated":
continue
if r.status not in dl.STATUSES or r.status == "legacy-unmigrated":
if r.status not in dl.STATUSES:
errs.append(f"{r.heading}: invalid status {r.status!r}")
if not r.key or not dl.KEY_RE.match(r.key):
errs.append(f"{r.heading}: bad key format {r.key!r}")
@@ -416,7 +457,7 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
if n > 1:
errs.append(f"key {key!r}: more than one active record ({n})")
# reciprocal supersession
# reciprocal supersession (successor may live in active OR archive)
for r in decision_recs:
sk = _key_of(r.superseded_by)
if sk and sk not in known_keys:
@@ -426,9 +467,9 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
errs.append(f"{r.heading}: supersedes points to unknown key {pk!r}")
for h in removed:
errs.append(
f"record removed from the active set without an archive copy: {h!r}"
)
errs.append(f"record removed from the active set without an archive copy: {h!r}")
for h in rewritten:
errs.append(f"rationale prose of {h!r} changed without the {EDIT_TOKEN} token")
if not catalog_ok:
errs.append("docs/decisions/README.md active catalog is stale — run build_decisions_catalog.py")
@@ -437,6 +478,106 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
return errs
# ---- git helpers (all fail-open: return neutral values on any error) ----
def _run(args: list[str]) -> str | None:
r = subprocess.run(args, capture_output=True, text=True)
return r.stdout if r.returncode == 0 else None
def _merge_base(base: str, head: str) -> str | None:
out = _run(["git", "merge-base", base, head])
return out.strip() if out else None
def _active_paths_at(ref: str) -> list[str]:
"""Active decision files at a ref: decisions.md + docs/decisions/*.md minus README/archive."""
out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions.md", "docs/decisions/"])
if out is None:
return []
paths = []
for p in out.splitlines():
if not p.endswith(".md"):
continue
if p.startswith("docs/decisions/archive/") or p == "docs/decisions/README.md":
continue
paths.append(p)
return paths
def _records_at(ref: str, paths: list[str]):
"""{heading: Record} across the given paths at a ref."""
by_heading = {}
for p in paths:
blob = _run(["git", "show", f"{ref}:{p}"])
if blob is None:
continue
for rec in dl.parse_text(blob, Path(p)):
if rec.heading not in SKIP_HEADINGS:
by_heading[rec.heading] = rec
return by_heading
def _archive_records_at(ref: str):
out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions/archive/"])
paths = [p for p in (out or "").splitlines() if p.endswith(".md")]
by_heading = {}
for p in paths:
blob = _run(["git", "show", f"{ref}:{p}"])
if blob is None:
continue
for rec in dl.parse_text(blob, Path(p)):
by_heading[rec.heading] = rec
return by_heading
def _rationale(rec) -> str:
"""Record body with the metadata block stripped, whitespace-normalized — the prose we protect."""
keep = []
seen_meta = False
for line in rec.body.splitlines():
s = line.strip()
if not seen_meta and (s.startswith("`key:") or s.startswith("**Rule:**")
or s.startswith("**Signals:**") or s.startswith("**Mechanics:**")):
seen_meta = True
continue
if seen_meta and (s.startswith("**Rule:**") or s.startswith("**Signals:**")
or s.startswith("**Mechanics:**") or s.startswith("`key:")):
continue
keep.append(s)
return "\n".join(x for x in keep if x)
def _diff_findings(base: str, head: str) -> tuple[list[str], list[str]]:
"""(removed, rewritten) between merge-base(base,head) and head. Fail-open → ([], [])."""
mb = _merge_base(base, head)
if not mb:
return [], []
if EDIT_TOKEN.lower() in (_run(["git", "log", "--format=%B", f"{mb}..{head}"]) or "").lower():
token = True
else:
token = False
base_active = _records_at(mb, _active_paths_at(mb))
head_active = _records_at(head, _active_paths_at(head))
head_archive = _archive_records_at(head)
gone = set(base_active) - set(head_active)
removed = sorted(h for h in gone if h not in head_archive)
rewritten: list[str] = []
if not token:
# surviving records whose rationale prose changed
for h in set(base_active) & set(head_active):
if _rationale(base_active[h]) != _rationale(head_active[h]):
rewritten.append(h)
# archived records must body-match their base active copy (no laundering rewrites via archive)
for h in gone & set(head_archive):
if _rationale(base_active[h]) != _rationale(head_archive[h]):
rewritten.append(h)
return removed, sorted(rewritten)
def _archive_keys() -> set[str]:
keys: set[str] = set()
if dl.ARCHIVE_DIR.exists():
@@ -447,42 +588,7 @@ def _archive_keys() -> set[str]:
return keys
def _removed_active(base: str, head: str) -> list[str]:
"""Headings present in active files at base but gone at head, not landing in archive."""
try:
out = subprocess.run(
["git", "diff", "--numstat", f"{base}...{head}", "--", "docs/decisions.md",
"docs/decisions/"],
capture_output=True, text=True, check=True,
).stdout
except Exception:
return [] # fail-open
# Compare parsed headings in active files between the two trees.
def headings_at(ref: str) -> set[str]:
res: set[str] = set()
for path in ("docs/decisions.md",):
blob = subprocess.run(["git", "show", f"{ref}:{path}"],
capture_output=True, text=True)
if blob.returncode == 0:
res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))}
return res
def archive_headings_at(ref: str) -> set[str]:
res: set[str] = set()
names = subprocess.run(["git", "ls-tree", "-r", "--name-only", ref,
"docs/decisions/archive/"], capture_output=True, text=True).stdout
for path in names.splitlines():
if path.endswith(".md"):
blob = subprocess.run(["git", "show", f"{ref}:{path}"],
capture_output=True, text=True)
if blob.returncode == 0:
res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))}
return res
gone = headings_at(base) - headings_at(head) - {"Index", "Active catalog"}
landed = archive_headings_at(head)
return sorted(gone - landed)
def _budget_ok(records, limit: int) -> bool:
def _budget_ok(limit: int) -> bool:
total = 0
for f in dl.active_files():
if f.exists():
@@ -497,7 +603,7 @@ def _catalog_ok() -> bool:
try:
import scripts.build_decisions_catalog as bc
except Exception:
return True # fail-open; generator task lands with this validator
return True # fail-open; the CI job also runs an independent --check step
want = bc.render_catalog(dl.all_active_records())
cat = dl.TOPIC_DIR / "README.md"
have = cat.read_text(encoding="utf-8") if cat.exists() else ""
@@ -512,13 +618,14 @@ def main(argv=None) -> int:
args = ap.parse_args(argv)
records = dl.all_active_records()
removed = _removed_active(args.base, args.head) if args.base and args.head else []
removed, rewritten = _diff_findings(args.base, args.head) if args.base and args.head else ([], [])
errs = validate(
records,
archive_keys=_archive_keys(),
catalog_ok=_catalog_ok(),
budget_ok=_budget_ok(records, args.budget),
budget_ok=_budget_ok(args.budget),
removed=removed,
rewritten=rewritten,
)
unmigrated = [r for r in records if r.status == "legacy-unmigrated"
@@ -622,9 +729,13 @@ def _rel(rec) -> str:
def _anchor(heading: str) -> str:
# Gitea/GitHub slugger: lowercase, keep alphanumerics + underscore, spaces/hyphens → '-',
# drop other punctuation. Keeping '_' matters — headings like "…(`iptv.base_url`)…" anchor to
# …iptvbase_url…. VERIFY against Gitea's actual output on 2-3 real headings before trusting links
# (the catalog is the startup surface; a dead link there is expensive).
out = []
for ch in heading.lower():
if ch.isalnum():
if ch.isalnum() or ch == "_":
out.append(ch)
elif ch in " -":
out.append("-")
@@ -696,23 +807,28 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): active-catalog generator"
**Interfaces:**
- Produces: exit 0 if no active file contains #237-as-live-state language; exit 1 + offending
file:line otherwise. Scans `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`,
`CLAUDE.md`. Allow-lists lines tagged `<!-- archival:237 -->` or within an "Archived / historical"
section.
file:line otherwise. Scans `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`,
`scripts/select-queue.sh`. A hit is exempt if the line contains `archiv` (case-insensitive) OR the
scanner is currently inside an archival section — a heading matching `^#{1,6}.*archiv` (case-
insensitive) opens the section; the next same-or-higher-level heading closes it. The `archiv`
substring is a deliberate anti-footgun escape hatch, not an anti-adversary control.
- [ ] **Step 1: Write `scripts/check-kickoff-guard.sh`**
```bash
#!/usr/bin/env bash
# ersatztv#520 — block re-treating the CLOSED arc tracker #237 as live queue state / source of truth.
# Scans active startup docs for forbidden phrasings. An intentional archival mention must carry the
# marker `<!-- archival:237 -->` on the same line (or be inside a line already containing "archiv").
# Scans active startup docs for forbidden phrasings. A hit is exempt when the line itself contains
# "archiv" OR the line is inside an archival section (a heading whose text contains "archiv", up to
# the next heading of the same or higher level). The "archiv" escape is an anti-footgun convenience,
# not an anti-adversary control.
set -euo pipefail
FILES=(
"docs/handoffs/chicorytv-issue-queue.md"
"docs/README.md"
"CLAUDE.md"
"scripts/select-queue.sh"
)
# Case-insensitive patterns that assert #237 is live/authoritative.
PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives in|read that, not this file'
@@ -720,12 +836,27 @@ PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives i
rc=0
for f in "${FILES[@]}"; do
[ -f "$f" ] || continue
while IFS=: read -r lineno content; do
[ -n "$lineno" ] || continue
printf '%s' "$content" | grep -qiE 'archiv|archival:237' && continue
echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${content}" >&2
rc=1
done < <(grep -niE "$PATTERNS" "$f" || true)
archive_level=0 # 0 = not in an archival section; else the heading level that opened it
lineno=0
while IFS= read -r line; do
lineno=$((lineno + 1))
# Track archival-section state via Markdown ATX headings.
if [[ "$line" =~ ^(#{1,6})[[:space:]] ]]; then
level=${#BASH_REMATCH[1]}
if printf '%s' "$line" | grep -qi 'archiv'; then
archive_level=$level
elif [ "$archive_level" -ne 0 ] && [ "$level" -le "$archive_level" ]; then
archive_level=0 # a same-or-higher heading closes the archival section
fi
continue
fi
[ "$archive_level" -ne 0 ] && continue # inside archival section → exempt
printf '%s' "$line" | grep -qi 'archiv' && continue # per-line escape
if printf '%s' "$line" | grep -qiE "$PATTERNS"; then
echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${line}" >&2
rc=1
fi
done < "$f"
done
[ "$rc" -eq 0 ] && echo "kickoff-guard: OK"
exit "$rc"
@@ -767,6 +898,15 @@ git -c core.hooksPath=/dev/null commit -m "feat(520): kickoff regression guard (
`**Signals:**`, `**Mechanics:**`.
- [ ] **Step 3** Do the same reconciliation for the #411 obsolescence (superseded measurement):
status `retired` or `superseded` per whether the governed surface still exists; archive if superseded.
- [ ] **Step 3b (dogfood supersession — Fable #5)** This PR *reverses a recorded decision*: the #303
H9 "docs/decisions.md is append-only" decision (in `docs/decisions/release-ci-governance.md`) is
replaced by the lifecycle validator. The very first PR under the new regime must model the same-PR
supersession it mandates. Add a NEW active record for the lifecycle decision (key
`docs.decision-lifecycle`, status `active`, `supersedes: docs.append-only-guard@2026-06`); give the
existing H9 record the key `docs.append-only-guard`, set `status: superseded`, `superseded-by:
docs.decision-lifecycle@2026-07-21`, and move it to `docs/decisions/archive/release-ci-governance.md`
with a back-link. (Note: the `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237)`
record is reversed by #520, not this PR — its supersession lands in PR2/Task 8.)
- [ ] **Step 4** Start `docs/decisions/migration-map.md`:
```markdown
@@ -811,22 +951,34 @@ C = `release-ci-governance.md`; D = `spa-modularization.md`; EH = `decisions.
slices). Dispatch one subagent per batch (balanced/cheap tier; each with its OWN worktree/branch off
the feature branch — never two committing agents on one worktree). Each subagent's brief:
- Read the schema format + one exemplar (paste both into the brief).
- Read the schema format + one exemplar (paste both into the brief) + **your batch's pre-assigned
heading→key table** (the orchestrator assigns keys; you do NOT invent them — see Step 0).
- For each `##` entry in your assigned file/range: insert the metadata block as the first non-blank
lines under the heading. Derive `key` from the subject (stable, dotted; REUSE an existing key only
for a genuine supersessor). `status: active` unless the prose says the decision was reversed/retired
(then flag it for the orchestrator — do NOT self-archive). `since` = the entry date. `**Rule:**` =
the entry's one-line current rule (from its own text). `**Signals:**` = concepts + `paths:` +
`issues:` mined from the entry. `**Mechanics:**` = any doc/source anchors the entry already cites.
- Do NOT alter rationale prose. Do NOT delete anything.
- Add a migration-map row per entry.
- Return the list of entries you flagged as reversed/retired for orchestrator reconciliation.
lines under the heading, using the key from the supplied table. `status: active` unless the prose
says the decision was reversed/retired (then flag it — do NOT self-archive, do NOT set a supersede
link). `since` = the entry date. `**Rule:**` = the entry's one-line current rule (from its own
text). `**Signals:**` = concepts + `paths:` + `issues:` mined from the entry. `**Mechanics:**` = any
doc/source anchors the entry already cites.
- Do NOT alter rationale prose. Do NOT delete anything. (The validator's body-diff will red-CI a
rationale change without `[decisions-edit]`; the migration commit carries the token, but keeping
prose byte-stable avoids masking a real edit.)
- **RETURN** (do not write) one migration-map row per entry + the list of entries you flagged as
reversed/retired. The orchestrator writes `migration-map.md` once, to avoid inter-batch conflicts.
- [ ] **Step 1** Dispatch batch subagents (23 concurrent; gate on free RAM). Collect flagged
reversals.
- [ ] **Step 2** Orchestrator reconciles every flagged reversal/retirement (assign supersede links,
move to `docs/decisions/archive/`, update predecessor). This is judgment work — not delegated.
- [ ] **Step 3** Merge batch branches back into the feature branch.
- [ ] **Step 0 (orchestrator, judgment — before fan-out)** Build the full heading→key table for the
whole corpus (this IS the migration-map skeleton). Assign stable dotted keys by *subject*, so related
decisions across batches share a family prefix and no two batches coin divergent keys for one
subject (the ≤1-active-per-key check catches collisions, NOT divergent keys). Reuse an existing key
only for a genuine supersessor.
- [ ] **Step 1** Dispatch batch subagents (23 concurrent; gate on free RAM), each with its key-table
slice. Collect returned rows + flagged reversals.
- [ ] **Step 1b (reversal backstop — Fable #7)** Cheap agents WILL miss prose reversals. Orchestrator
greps the whole corpus for `supersed|reversed|obsolete|no longer|retired|deprecat` and reconciles
every hit against the flag list — a hit not already flagged is a missed reversal to resolve.
- [ ] **Step 2** Orchestrator reconciles every flagged/found reversal/retirement (assign supersede
links, move to `docs/decisions/archive/`, update predecessor). Judgment — not delegated.
- [ ] **Step 3** Merge batch branches back into the feature branch; orchestrator writes
`docs/decisions/migration-map.md` once from the returned rows.
- [ ] **Step 4** Regenerate catalog: `PYTHONPATH=. python scripts/build_decisions_catalog.py`.
- [ ] **Step 5** Run validator: `PYTHONPATH=. python scripts/decisions_validate.py` — expect OK and
**0** legacy-unmigrated (or an explicitly-listed, tracked remainder if the safety valve is invoked).
@@ -840,30 +992,39 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): migrate decision corpus to
---
## Task 7: Retire the append-only line guard + `[decisions-edit]`; wire the validator
## Task 7: Rework the guard — retire the line-level mechanic, keep `[decisions-edit]` narrow; wire the validator
**Files:**
- Modify: `.claude/hooks/decisions-guard.sh` (replace body with a validator shim, or delete + rewire).
- Modify: `.husky/commit-msg`, `.husky/pre-commit` (call validator instead of append-only guard).
- Modify: `.claude/hooks/decisions-guard.sh` (replace body with a validator shim).
- Modify: `.husky/commit-msg`, `.husky/pre-commit` (call validator instead of append-only line guard).
- Modify: `.gitea/workflows/docker-build.yml` (`decisions-guard` job).
- Modify: `docs/decisions.md` header (append-only framing → lifecycle framing).
- Modify: `docs/ci-cd.md` (release ritual; drop `[decisions-edit]`, aggregate budget).
- Modify: `docs/ci-cd.md` (release ritual; re-scope `[decisions-edit]`, aggregate budget).
- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin wrapper:
- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin, **fail-open** wrapper (Fable
#12ii — python missing must NOT wedge a commit):
```bash
#!/usr/bin/env bash
# ersatztv#521 — the append-only line guard is retired. Decision integrity is now enforced by the
# lifecycle validator. This shim keeps the old hook path working: it runs the validator over the
# working tree. Fail-open on tooling trouble.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 0
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
PYTHONPATH=. python3 scripts/decisions_validate.py
rc=$?
[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks
exit 0 # crashes/other codes -> fail-open
```
- [ ] **Step 2** Update `.husky/commit-msg`: remove the `[decisions-edit]`/append-only block; the
Co-Authored-By check stays. Add the validator to `.husky/pre-commit` (or keep it in commit-msg
via the shim). Verify `grep -rn 'decisions-edit' .husky/` returns nothing.
- [ ] **Step 2** Update `.husky/commit-msg`: remove the OLD append-only `deleted>0 → require token`
block (`decisions-guard.sh staged`); keep the Co-Authored-By check. Call the shim from
`.husky/pre-commit` (structural check on the working tree). The token's remaining job — gating
rationale-body edits — is enforced by the CI body-diff (Step 3), which is the only place a
base/head exists. Verify `grep -rn 'decisions-edit' .husky/` returns nothing (the token is no longer
a hook concept; it lives in commit messages + the CI validator).
- [ ] **Step 3** Update the CI `decisions-guard` job in `.gitea/workflows/docker-build.yml`:
replace the `decisions-guard.sh range` call with:
@@ -874,24 +1035,29 @@ PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
```
Remove the 1800-line-floor block (now the validator's aggregate budget). Rename the job label to
`decisions lifecycle`.
(The **kickoff guard** CI step is NOT added here — it lands in PR2/Task 8, together with the doc
rewrite that removes the "read #237" language it flags; wiring it in PR1 would red-CI on the
still-uncleaned docs.) Remove the 1800-line-floor block (now the validator's aggregate budget).
Rename the job label to `decisions lifecycle`. **Verify `python3` is present on the runner lane this
job uses** (Fable #12i — the bare `small` lane may lack it): if absent, add `container:` or an
install step, or keep the job on a python-bearing lane.
- [ ] **Step 4** Rewrite the `docs/decisions.md` header: append-only → lifecycle; document the
metadata schema, the four statuses, the catalog + archive, and that supersession is same-PR. Remove
`[decisions-edit]` instructions.
metadata schema, the statuses, the catalog + archive, that supersession is same-PR, and that
`[decisions-edit]` is now required ONLY for a rationale-prose edit / factual correction (routine
lifecycle metadata writes are token-free). Do not remove the token — re-scope it.
- [ ] **Step 5** Update `docs/ci-cd.md` release ritual (validate lifecycle + reciprocal links; archive
already-classified history; refresh catalog; enforce aggregate budget; report unresolved legacy).
Re-scope, don't delete, the `[decisions-edit]` note.
- [ ] **Step 6** Run: `bash .claude/hooks/decisions-guard.sh` (shim) → validator OK.
`grep -rn 'decisions-edit' docs/ .husky/ .gitea/ .claude/` → only historical/archival mentions.
`grep -rn 'decisions-edit' .husky/` → nothing (not a hook concept). `grep -rn 'decisions-edit'
docs/decisions.md docs/ci-cd.md` → the re-scoped narrow-usage note (expected).
- [ ] **Step 7: Commit**
```bash
git add .claude/hooks/decisions-guard.sh .husky/ .gitea/workflows/docker-build.yml docs/decisions.md docs/ci-cd.md
git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line guard + [decisions-edit]; wire lifecycle validator into CI"
git -c core.hooksPath=/dev/null commit -m "feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header rewrite"
```
---
@@ -915,16 +1081,34 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line gu
record shape.
- [ ] **Step 3** `CLAUDE.md`: docs-first guidance → "read the `docs/README.md` map + the sections it
points to, not the whole corpus." Add the `## Closing record` template to the Task Completion
Protocol. Remove `[decisions-edit]` mentions; point decision lookups at the catalog.
Protocol. Point decision lookups at the catalog (`docs/decisions/README.md`). Update `[decisions-edit]`
mentions to the re-scoped narrow meaning (rationale-prose edits only) — do not delete.
- [ ] **Step 3b (MemPalace retrieval rules — #520 done-when, Fable #9)** Add a short "Knowledge
retrieval" block to the kickoff (and/or `docs/README.md`): **MemPalace = candidate discovery only**,
constrained to the ErsatzTV/project wing, retrieved at a bounded `k`, and every passage **verified
against its cited Markdown/Gitea source** before use; if MemPalace is unavailable or stale, fall back
to the `docs/README.md` task map + bounded exact search; **never** derive live queue state from
MemPalace, #237, or historical comments. Without this the #520 "MemPalace candidate discovery +
canonical verification + exact-search fallback documented" box is unmet.
- [ ] **Step 3c (dogfood supersession — Fable #5, PR2 half)** #520 reverses the recorded decision
`## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237)`. Give it key
`queue.state-source`, `status: superseded`, `superseded-by: startup.parallel-orientation@2026-07-21`,
and archive it; add the NEW active record `startup.parallel-orientation` (the two-track startup +
#237-retirement decision) with `supersedes: queue.state-source@2026-07-11`. Commit range carries
`[decisions-edit]` (archiving + the superseded record's body is preserved in archive).
- [ ] **Step 4** `scripts/select-queue.sh`: update header comment + any echoed guidance so tiers lead
with open milestones/review/priority and no line says "read #237."
- [ ] **Step 5** Run `bash scripts/check-kickoff-guard.sh` → **exit 0** now (the language is gone).
This flips the Task 4 non-vacuity failure to green — the regression box is satisfied.
- [ ] **Step 5** Add the kickoff-guard CI step to the `decisions lifecycle` job (deferred from
PR1/Task 7): `- name: Kickoff guard` / `run: bash scripts/check-kickoff-guard.sh`. Run
`bash scripts/check-kickoff-guard.sh` locally → **exit 0** now (the language is gone). This wires +
satisfies the #520 "cannot regress" box in the same PR that cleans the docs.
- [ ] **Step 6: Commit**
```bash
git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh
git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state"
git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh \
scripts/check-kickoff-guard.sh scripts/tests/test_kickoff_guard.sh \
.gitea/workflows/docker-build.yml docs/decisions.md docs/decisions/
git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state [decisions-edit]"
```
---
@@ -966,11 +1150,12 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba
`PYTHONPATH=. python scripts/build_decisions_catalog.py --check`;
`bash scripts/check-kickoff-guard.sh`.
- [ ] **Step 2 (startup cold-agent sim, #520 done-when)** Dispatch a fresh general-purpose subagent
given ONLY the new `docs/handoffs/chicorytv-issue-queue.md` + `docs/README.md` (no #237 access) and
the prompt "you are a fresh orchestrator, no issue named — describe your exact startup steps." PASS
iff: runs orientation ‖ `select-queue.sh 5`, resolves only CLAIM?/UMBRELLA? flags + winner, rechecks
live state, claims, focused retrieval — and never reads/consults #237. Record the transcript verdict
in the PR.
given the new `docs/handoffs/chicorytv-issue-queue.md` + `docs/README.md` AND **normal Gitea read
access** (Fable #14 — withholding #237 makes "never reads #237" unfalsifiable; give it the ability
and observe whether it *chooses* to). Prompt: "you are a fresh orchestrator, no issue named —
describe and take your exact startup steps." PASS iff: runs orientation ‖ `select-queue.sh 5`,
resolves only CLAIM?/UMBRELLA? flags + winner, rechecks live state, claims, focused retrieval — and
**never fetches/reads #237** as a queue source. Record the transcript verdict in the PR.
- [ ] **Step 3 (retrieval cold-agent sim, #521)** Dispatch a fresh subagent given only
`docs/decisions/README.md` + the corpus; feed 5 of the `retrieval-eval.md` questions; PASS iff it
cites the expected active keys and does NOT pick a superseded/archived record as current.
@@ -991,6 +1176,20 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba
`docs/handoffs/chicorytv-issue-queue.md` (kickoff). MEMORY.md updated separately (operator memory,
not in-repo): retire `[decisions-edit]` note, add lifecycle-validator + catalog pointers.
## Fable review fold (2026-07-21)
Verdict SOUND-WITH-FIXES; all findings folded: no-vanish now enumerates all active files at
`merge-base` (BLOCKERS #1/#2); body-diff added so silent rationale rewrite is blocked (BLOCKER #3);
`[decisions-edit]` kept narrow for rationale edits (SHOULD #4, user-confirmed); dogfood supersession
of the append-only decision (PR1/Task 5 Step 3b) and the #237 queue-state decision (PR2/Task 8 Step
3c) (BLOCKER #5); two-PR split (SHOULD #6, user-confirmed); orchestrator assigns keys + reversal-grep
backstop + batches return map rows (SHOULD #7); section-aware kickoff guard + `select-queue.sh` in
scope (SHOULD #8); MemPalace retrieval-rules step (SHOULD #9, Task 8 Step 3b); `_anchor` keeps `_`
(SHOULD #10); parser stops at blank line (#11); fail-open shim + python3-on-lane check (#12);
signature/dead-code tidy (#13); cold-agent sim gets Gitea access (#14).
**Open owner:** the aggregate `--budget` starts at 4200 and must be *ratcheted down* as archive grows
— that ownership lives in the `docs/ci-cd.md` release ritual (Task 7 Step 5).
## Self-review notes (spec coverage)
- #521 done-when: schema (Task 1/5/6), same-PR supersession (Task 5/6 + validator Task 2), active