# Decision-lifecycle + retrieval-efficient startup — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give ErsatzTV decision records a stable key + explicit lifecycle + a generated compact
active view (#521), replace the line-level append-only guard with a lifecycle validator, and rewire
startup to run orientation ‖ selection and stop treating closed tracker #237 as live state (#520).

**Architecture:** A shared Python parser (`decisions_lib.py`) reads H2 decision records + a visible
metadata block from `docs/decisions.md` and the topic files. Two generators/validators build on it:
`build-decisions-catalog.py` regenerates `docs/decisions/README.md` (the active catalog), and
`decisions-validate.py` enforces lifecycle invariants (≤1 active/key, reciprocal supersession,
no-vanish, catalog freshness, aggregate budget). A bash `check-kickoff-guard.sh` blocks
#237-as-live-state language. Existing entries are migrated into schema form by fanned-out subagents;
superseded/retired records move to `docs/decisions/archive/`. CI's `decisions-guard` job is
extended to run the validators; the `[decisions-edit]` token and its Husky/CI wiring are retired.

**Tech Stack:** Python 3 (stdlib only; ruff/pyright/pytest via `uv` locally), Bash, Markdown, Gitea
Actions (`.gitea/workflows/docker-build.yml`), Husky hooks.

## Global Constraints

- Work in the worktree `feat/520-521-decision-lifecycle` off `origin/main`. Never commit in
  `/Users/timothy/ersatztv`.
- **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:
  ```
  `key: <dotted.key>` · `status: <active|superseded|retired>` · `since: <YYYY-MM-DD>` · `supersedes: <key@date|none>` · `superseded-by: <key@date|none>`
  **Rule:** <one line>
  **Signals:** <concepts> · paths: <a/b> · issues: #NNN
  **Mechanics:** <doc/source/test anchors>
  ```
- `key` format: `^[a-z0-9]+(\.[a-z0-9-]+)+$` (dotted, lowercase, hyphens ok), e.g. `ci.runner-placement`.
- `status` enum: `active`, `superseded`, `retired` (records; `legacy-unmigrated` is the *implicit*
  status of an unmigrated entry — no metadata line — and must trend to 0 by end of migration).
- Python scripts: stdlib only, `#!/usr/bin/env python3`, module docstring, `ruff`/`pyright` clean.
  Tests are pytest under `scripts/tests/`, run with `python -m pytest scripts/tests -q`.
- Every `.cs`? None in this arc. No OpenAPI regen. No DB migration.
- Never set `ETV_UPDATE_GOLDENS`.

---

## File Structure

**Create:**
- `scripts/decisions_lib.py` — shared parser (records + metadata) over the active decision files.
- `scripts/decisions_validate.py` — lifecycle-invariant validator (CLI, `--base/--head` for no-vanish).
- `scripts/build_decisions_catalog.py` — regenerate/`--check` `docs/decisions/README.md`.
- `scripts/check-kickoff-guard.sh` — grep guard against #237-as-live-state language.
- `scripts/tests/test_decisions_lib.py`, `scripts/tests/test_decisions_validate.py`,
  `scripts/tests/fixtures/…` — pytest suite.
- `docs/decisions/README.md` — GENERATED active catalog / task router.
- `docs/decisions/archive/` (+ `docs/decisions/archive/README.md`) — superseded/retired records.
- `docs/decisions/migration-map.md` — auditable legacy-heading → active-record/archive mapping.
- `docs/decisions/retrieval-eval.md` — the retrieval question bank + expected active-record answers.

**Modify:**
- `docs/decisions.md` — header reframed (append-only → lifecycle); every in-file entry migrated to schema.
- `docs/decisions/{optimistic-concurrency,api-auth-security,release-ci-governance,spa-modularization}.md`
  — entries migrated to schema.
- `.claude/hooks/decisions-guard.sh` — replaced by a thin shim calling the validator (or removed +
  hook rewired).
- `.husky/commit-msg` / `.husky/pre-commit` — swap the append-only call for the validator.
- `.gitea/workflows/docker-build.yml` — `decisions-guard` job → runs validator + catalog `--check` +
  kickoff guard; drop `[decisions-edit]`/size-floor text.
- `docs/handoffs/chicorytv-issue-queue.md` — startup rewrite (#520).
- `docs/README.md` — reading-order → task-signal map + active-catalog pointer.
- `CLAUDE.md` — docs-first guidance + `## Closing record` template + drop `[decisions-edit]` mentions.
- `docs/ci-cd.md` — release ritual rewrite; drop `[decisions-edit]`; aggregate budget.
- `scripts/select-queue.sh` — comment/output text (tiers lead with milestones/review/priority; no "read #237").

---

## Task 1: Shared decision-record parser (`decisions_lib.py`)

**Files:**
- Create: `scripts/decisions_lib.py`
- Test: `scripts/tests/test_decisions_lib.py`, `scripts/tests/fixtures/sample_decisions.md`

**Interfaces:**
- Produces: `Record` dataclass (`heading, source, lineno, key, status, since, supersedes,
  superseded_by, rule, signals, mechanics, body`); `parse_file(path) -> list[Record]`;
  `active_files() -> list[Path]`; `all_active_records() -> list[Record]`; module constants
  `STATUSES`, `KEY_RE`, `DECISIONS_MD`, `TOPIC_DIR`, `ARCHIVE_DIR`.

- [ ] **Step 1: Write the fixture** `scripts/tests/fixtures/sample_decisions.md`

```markdown
# Decisions

## Index
- [x](#y)

## 2026-07-17 — Migrated example (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** Every CI services container gets an explicit cap.
**Signals:** ci, runners · paths: .gitea/workflows/docker-build.yml · issues: #390 #406
**Mechanics:** docs/ci-cd.md → CI lanes

Rationale prose here.

## 2026-07-11 — Legacy unmigrated example (#231)
Some prose with no metadata line at all.
```

- [ ] **Step 2: Write the failing test** `scripts/tests/test_decisions_lib.py`

```python
from pathlib import Path
import scripts.decisions_lib as dl

FIX = Path(__file__).parent / "fixtures" / "sample_decisions.md"


def test_parses_migrated_record():
    recs = dl.parse_file(FIX)
    migrated = [r for r in recs if r.key == "ci.runner-placement"]
    assert len(migrated) == 1
    r = migrated[0]
    assert r.status == "active"
    assert r.since == "2026-07-17"
    assert r.supersedes == "none"
    assert r.superseded_by == "none"
    assert r.rule == "Every CI services container gets an explicit cap."
    assert "issues: #390 #406" in r.signals
    assert r.mechanics.startswith("docs/ci-cd.md")


def test_legacy_record_is_unmigrated():
    recs = dl.parse_file(FIX)
    legacy = [r for r in recs if r.heading.endswith("(#231)")]
    assert len(legacy) == 1
    assert legacy[0].key is None
    assert legacy[0].status == "legacy-unmigrated"


def test_index_section_is_not_a_record():
    recs = dl.parse_file(FIX)
    assert all(r.heading != "Index" for r in recs)
```

Note: the `Index` H2 has no metadata → parsed as a legacy record with heading `Index`; the validator
(Task 2) skips a configured set of non-decision headings. This test asserts we can identify it, so
here it's acceptable that `Index` parses as a record — adjust assertion to
`assert any(r.heading == "Index" for r in recs)` and let Task 2 own the skip-list. **Use this
corrected assertion:**

```python
def test_index_section_parses_as_heading():
    recs = dl.parse_file(FIX)
    assert any(r.heading == "Index" for r in recs)
```

- [ ] **Step 3: Run test to verify it fails**

Run: `python -m pytest scripts/tests/test_decisions_lib.py -q`
Expected: FAIL (`ModuleNotFoundError: scripts.decisions_lib`)

- [ ] **Step 4: Implement `scripts/decisions_lib.py`**

```python
#!/usr/bin/env python3
"""Parse ErsatzTV decision records from docs/decisions.md and docs/decisions/*.md.

A decision record is a Markdown H2 section. A *migrated* record carries a visible metadata
block as its first non-blank content:

    ## 2026-07-17 — Title … (#406)
    `key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
    **Rule:** one-line current rule.
    **Signals:** concept · paths: a/b.yml · issues: #406
    **Mechanics:** docs/ci-cd.md → CI lanes
    <rationale prose …>

An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"

STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")


@dataclass
class Record:
    heading: str
    source: Path
    lineno: int
    key: str | None = None
    status: str = "legacy-unmigrated"
    since: str | None = None
    supersedes: str | None = None
    superseded_by: str | None = None
    rule: str | None = None
    signals: str | None = None
    mechanics: str | None = None
    body: str = ""


def _parse_meta_line(line: str) -> dict[str, str]:
    return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}


def parse_text(text: str, source: Path) -> list[Record]:
    lines = text.splitlines()
    records: list[Record] = []
    i = 0
    while i < len(lines):
        m = _HEADING_RE.match(lines[i])
        if not m:
            i += 1
            continue
        rec = Record(heading=m.group(1), source=source, lineno=i + 1)
        j = i + 1
        body_lines: list[str] = []
        while j < len(lines) and not _HEADING_RE.match(lines[j]):
            body_lines.append(lines[j])
            j += 1
        for k, bl in enumerate(body_lines):
            if not bl.strip():
                continue
            meta = _parse_meta_line(bl)
            if "key" in meta and "status" in meta:
                rec.key = meta.get("key") or None
                rec.status = meta.get("status") or "legacy-unmigrated"
                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:**"):
                        rec.signals = bl2[len("**Signals:**") :].strip()
                    elif bl2.startswith("**Mechanics:**"):
                        rec.mechanics = bl2[len("**Mechanics:**") :].strip()
            break
        rec.body = "\n".join(body_lines).strip()
        records.append(rec)
        i = j
    return records


def parse_file(path: Path) -> list[Record]:
    return parse_text(path.read_text(encoding="utf-8"), path)


def active_files() -> list[Path]:
    files = [DECISIONS_MD]
    files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name != "README.md")
    return files


def all_active_records() -> list[Record]:
    recs: list[Record] = []
    for f in active_files():
        if f.exists():
            recs += parse_file(f)
    return recs
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `python -m pytest scripts/tests/test_decisions_lib.py -q` (from repo root, so `scripts` is importable; add `scripts/tests/__init__.py` and `scripts/__init__.py` if needed, or run with `PYTHONPATH=.`)
Expected: PASS (3 tests). Also run `ruff check scripts/decisions_lib.py && pyright scripts/decisions_lib.py`.

- [ ] **Step 6: Commit**

```bash
git add scripts/decisions_lib.py scripts/tests/
git -c core.hooksPath=/dev/null commit -m "feat(521): decision-record parser (decisions_lib)"
```

---

## Task 2: Lifecycle validator (`decisions-validate.py`)

**Files:**
- Create: `scripts/decisions-validate.py`
- Test: `scripts/tests/test_decisions_validate.py` (+ good/bad fixtures)

**Interfaces:**
- 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,
  archive_records=None) -> 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.
- **Archive-placement invariant** (folded post-review; coordinated with server-management#642 so
  supersession = a physical move, not an in-place `status:` flip): a `superseded`/`retired` record in
  the ACTIVE files is an error ("relocate to `docs/decisions/archive/`"); an `active` record under
  `docs/decisions/archive/` is an error. This makes the MemPalace wing boundary (`ErsatzTV-Decisions`
  active vs `ErsatzTV-Decisions-Archive`) hold by construction.
- **Bounded body-strip** (folded post-review): `_rationale()` strips ONLY the contiguous top metadata
  block (first non-blank `` `key:`` line → first following blank line), mirroring `decisions_lib`'s
  parser — so a `**Rule:**`-prefixed line deep in rationale prose is prose, not silently laundered
  past the body-diff.
- 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`

```python
import importlib
from pathlib import Path
import scripts.decisions_lib as dl

dv = importlib.import_module("scripts.decisions-validate".replace("-", "_")) \
    if False else __import__("importlib").import_module("scripts.decisions_validate")
```

Note: a hyphenated filename can't be imported directly. **Name the module `decisions_validate.py`**
(underscore) and give the CLI entry via `python scripts/decisions_validate.py`. Update the File
Structure name accordingly for both validator and generator? No — keep the generator/validator
importable: use underscores in filenames (`decisions_validate.py`, `build_decisions_catalog.py`),
matching `decisions_lib.py`. CLI callers use the underscore names. **Corrected test header:**

```python
from pathlib import Path
import scripts.decisions_lib as dl
import scripts.decisions_validate as dv


def _rec(**kw):
    base = dict(heading="H", source=Path("x"), lineno=1, status="active")
    base.update(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():
    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():
    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")]
    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():
    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():
    assert _v([_rec(key="a.b"), _rec(key="c.d")]) == []
```

- [ ] **Step 2: Run to verify fail**

Run: `PYTHONPATH=. python -m pytest scripts/tests/test_decisions_validate.py -q`
Expected: FAIL (`ModuleNotFoundError: scripts.decisions_validate`)

- [ ] **Step 3: Implement `scripts/decisions_validate.py`**

```python
#!/usr/bin/env python3
"""Validate ErsatzTV decision-record lifecycle invariants.

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. `[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

import argparse
import subprocess
import sys
from pathlib import Path

import scripts.decisions_lib as dl  # noqa: E402  (run with PYTHONPATH=. or as module)

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:
    if not ref or ref == "none":
        return None
    return ref.split("@", 1)[0].strip()


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 and r.status != "legacy-unmigrated"
    ]

    active_by_key: dict[str, int] = {}
    known_keys = set(archive_keys)
    for r in decision_recs:
        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}")
            continue
        known_keys.add(r.key)
        for f in REQUIRED_META:
            if getattr(r, f) in (None, ""):
                errs.append(f"{r.heading}: missing required metadata {f}")
        if r.status == "active":
            active_by_key[r.key] = active_by_key.get(r.key, 0) + 1

    for key, n in active_by_key.items():
        if n > 1:
            errs.append(f"key {key!r}: more than one active record ({n})")

    # 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:
            errs.append(f"{r.heading}: superseded-by points to unknown key {sk!r}")
        pk = _key_of(r.supersedes)
        if pk and pk not in known_keys:
            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}")
    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")
    if not budget_ok:
        errs.append("aggregate active-corpus budget exceeded (see --budget)")
    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():
        for f in dl.ARCHIVE_DIR.glob("*.md"):
            for r in dl.parse_file(f):
                if r.key:
                    keys.add(r.key)
    return keys


def _budget_ok(limit: int) -> bool:
    total = 0
    for f in dl.active_files():
        if f.exists():
            total += len(f.read_text(encoding="utf-8").splitlines())
    cat = dl.TOPIC_DIR / "README.md"
    if cat.exists():
        total += len(cat.read_text(encoding="utf-8").splitlines())
    return total <= limit


def _catalog_ok() -> bool:
    try:
        import scripts.build_decisions_catalog as bc
    except Exception:
        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 ""
    return want.strip() == have.strip()


def main(argv=None) -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--base")
    ap.add_argument("--head")
    ap.add_argument("--budget", type=int, default=4200)  # aggregate; ratchet down as archive grows
    args = ap.parse_args(argv)

    records = dl.all_active_records()
    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(args.budget),
        removed=removed,
        rewritten=rewritten,
    )

    unmigrated = [r for r in records if r.status == "legacy-unmigrated"
                  and r.heading not in SKIP_HEADINGS]
    if unmigrated:
        print(f"::notice::{len(unmigrated)} legacy-unmigrated decision record(s) remain "
              f"(must trend to 0).")
    if errs:
        for e in errs:
            print(f"decisions-validate: {e}", file=sys.stderr)
        return 1
    print("decisions-validate: OK")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

- [ ] **Step 4: Run tests to verify pass**

Run: `PYTHONPATH=. python -m pytest scripts/tests/test_decisions_validate.py -q`
Expected: PASS (5 tests). Then `ruff check` + `pyright` on the file.

- [ ] **Step 5: Commit**

```bash
git add scripts/decisions_validate.py scripts/tests/test_decisions_validate.py
git -c core.hooksPath=/dev/null commit -m "feat(521): decision lifecycle validator"
```

---

## Task 3: Active-catalog generator (`build_decisions_catalog.py`)

**Files:**
- Create: `scripts/build_decisions_catalog.py`, `docs/decisions/README.md` (generated output)
- Test: `scripts/tests/test_build_catalog.py`

**Interfaces:**
- Consumes: `decisions_lib.all_active_records`, `Record`.
- Produces: `render_catalog(records) -> str` (full Markdown for `docs/decisions/README.md`);
  CLI `--check` (exit 1 if on-disk differs) / default write.

- [ ] **Step 1: Write failing test** `scripts/tests/test_build_catalog.py`

```python
from pathlib import Path
import scripts.decisions_lib as dl
import scripts.build_decisions_catalog as bc


def test_catalog_lists_only_active_sorted_by_key():
    recs = [
        dl.Record(heading="H2", source=Path("x"), lineno=1, key="z.a", status="active",
                  rule="Zeta rule"),
        dl.Record(heading="H1", source=Path("x"), lineno=1, key="a.b", status="active",
                  rule="Alpha rule"),
        dl.Record(heading="Old", source=Path("x"), lineno=1, key="a.b", status="superseded",
                  rule="old"),
    ]
    out = bc.render_catalog(recs)
    assert "a.b" in out and "z.a" in out
    assert out.index("a.b") < out.index("z.a")  # sorted
    assert "Alpha rule" in out and "Zeta rule" in out
    assert "old" not in out  # superseded excluded
    assert "GENERATED" in out  # do-not-edit banner
```

- [ ] **Step 2: Run to verify fail**

Run: `PYTHONPATH=. python -m pytest scripts/tests/test_build_catalog.py -q` → FAIL (module missing).

- [ ] **Step 3: Implement `scripts/build_decisions_catalog.py`**

```python
#!/usr/bin/env python3
"""Generate docs/decisions/README.md — the compact ACTIVE decision catalog / task router.

Do not edit docs/decisions/README.md by hand; regenerate with this script. The decisions-validate
CI job checks it is in sync.
"""
from __future__ import annotations

import sys
from pathlib import Path

import scripts.decisions_lib as dl

OUTPUT = dl.TOPIC_DIR / "README.md"
BANNER = "<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->"


def _rel(rec) -> str:
    """Link from docs/decisions/README.md to the record's heading."""
    src = Path(rec.source).name
    anchor = _anchor(rec.heading)
    if src == "decisions.md":
        return f"../decisions.md#{anchor}"
    return f"{src}#{anchor}"


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() or ch == "_":
            out.append(ch)
        elif ch in " -":
            out.append("-")
    return "".join(out)


def render_catalog(records) -> str:
    active = sorted(
        [r for r in records if r.status == "active" and r.key],
        key=lambda r: r.key,
    )
    lines = [
        BANNER,
        "",
        "# Active decisions — catalog / task router",
        "",
        "The compact current view of settled decisions. Each row is an **active** record; follow",
        "the link for rationale. Superseded/retired history lives in `archive/`. Regenerated by",
        "`scripts/build_decisions_catalog.py`.",
        "",
        "| Key | Current rule | Since | Record |",
        "| --- | --- | --- | --- |",
    ]
    for r in active:
        rule = (r.rule or "").replace("|", "\\|")
        lines.append(f"| `{r.key}` | {rule} | {r.since or ''} | [link]({_rel(r)}) |")
    lines.append("")
    return "\n".join(lines)


def main(argv=None) -> int:
    check = "--check" in (argv or sys.argv[1:])
    want = render_catalog(dl.all_active_records())
    have = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
    if check:
        if want.strip() != have.strip():
            print("build_decisions_catalog: docs/decisions/README.md is stale — regenerate.",
                  file=sys.stderr)
            return 1
        print("build_decisions_catalog: catalog up to date")
        return 0
    OUTPUT.write_text(want + "\n", encoding="utf-8")
    print(f"build_decisions_catalog: wrote {OUTPUT}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

- [ ] **Step 4: Run test to verify pass**

Run: `PYTHONPATH=. python -m pytest scripts/tests/test_build_catalog.py -q` → PASS. `ruff`/`pyright` clean.

- [ ] **Step 5: Commit**

```bash
git add scripts/build_decisions_catalog.py scripts/tests/test_build_catalog.py
git -c core.hooksPath=/dev/null commit -m "feat(521): active-catalog generator"
```

---

## Task 4: Kickoff regression guard (`check-kickoff-guard.sh`)

**Files:**
- Create: `scripts/check-kickoff-guard.sh`
- Test: `scripts/tests/test_kickoff_guard.sh` (bash asserts, run in CI/local)

**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`,
  `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. 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'

rc=0
for f in "${FILES[@]}"; do
  [ -f "$f" ] || continue
  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"
```

- [ ] **Step 2: Verify it FAILS on current tree (proves non-vacuity)**

Run: `bash scripts/check-kickoff-guard.sh` against the *pre-rewrite* files.
Expected: exit 1, flags `docs/handoffs/chicorytv-issue-queue.md` + `docs/README.md` (both currently
say "read #237"). This confirms the guard bites before Task 8 cleans the language.

- [ ] **Step 3: Commit**

```bash
git add scripts/check-kickoff-guard.sh scripts/tests/test_kickoff_guard.sh
git -c core.hooksPath=/dev/null commit -m "feat(520): kickoff regression guard (blocks #237-as-live-state)"
```

---

## Task 5: Reconcile prose-only reversals; seed schema exemplars

**Files:**
- Modify: `docs/decisions.md` (the #406 entry — add supersession metadata; the #390 lane entry if
  present as its own record), the #411 measurement obsolescence.
- Create: `docs/decisions/migration-map.md` (start the auditable mapping).

**Interfaces:**
- Produces: 2–3 fully-migrated exemplar records the subagents in Task 6 copy as the pattern.

- [ ] **Step 1** Read the #406 entry (`docs/decisions.md` around the "No persistent compiler servers"
  heading) and the #411-related text. Identify the reversal pair: #390's small-lane move (predecessor)
  → #406 (reverses it). Assign keys: `ci.runner-placement` (the #406 active record). If #390 exists as
  a distinct record, mark it `status: superseded`, `superseded-by: ci.runner-placement@2026-07-17`,
  and move it to `docs/decisions/archive/ci.md` with a back-link; give #406 `supersedes:
  <#390-key>@<date>`. If #390 only ever lived as prose inside #406, record that in the migration map
  (no separate record) and #406 carries `supersedes: none` with a rationale note.
- [ ] **Step 2** Add the full metadata block to #406 (see Global Constraints format). Add `**Rule:**`,
  `**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
# Decision migration map (legacy heading → active record / archive)

Auditable mapping produced during the #521 lifecycle migration. Every legacy H2 heading maps to
either an active record (key) or an archive location.

| Legacy heading | Key | Status | Location |
| --- | --- | --- | --- |
| 2026-07-17 — No persistent compiler servers in CI … (#406) | ci.runner-placement | active | docs/decisions.md |
```

- [ ] **Step 5** Run `PYTHONPATH=. python scripts/decisions_validate.py` — expect OK plus a
  `::notice::` legacy-unmigrated count (still high; migration ongoing). Regenerate catalog:
  `PYTHONPATH=. python scripts/build_decisions_catalog.py`.
- [ ] **Step 6: Commit**

```bash
git add docs/decisions.md docs/decisions/ 
git -c core.hooksPath=/dev/null commit -m "feat(521): reconcile #390/#406 + #411 reversals; seed schema exemplars [decisions-edit]"
```

(Interim commits may still carry `[decisions-edit]` while the old hook exists; Task 7 removes it.)

---

## Task 6: Full entry migration (subagent fan-out)

**Files:**
- Modify: every entry in `docs/decisions.md` + the four topic files.
- Modify: `docs/decisions/migration-map.md` (one row per entry).
- Create: records under `docs/decisions/archive/*.md` for any superseded/retired entry.

**Interfaces:**
- Consumes: the schema format (Global Constraints) + the Task 5 exemplars.
- Produces: 0 `legacy-unmigrated` records; validator + catalog green.

**Orchestration (not a code step — the executor runs this):** Split the corpus into bounded batches
by topic/date range (e.g. batch A = topic file `optimistic-concurrency.md`; B = `api-auth-security.md`;
C = `release-ci-governance.md`; D = `spa-modularization.md`; E–H = `decisions.md` in ~10-entry
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) + **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, 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 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 (2–3 concurrent; gate on free RAM), each with its key-table
  slice. Collect returned rows + flagged reversals.
- [ ] **Step 0b (close the archive↔archive reciprocity gap — carried from PR1 whole-branch review)**
  PR1's reciprocal-supersession check in `scripts/decisions_validate.py::validate` only iterates active
  `decision_recs`, so a back-link between TWO archived records (a twice-reversed decision, first appears
  in PR2's migration) is unchecked from either side. Before migrating any archive→archive chain, extend
  the reciprocity loop to iterate the active+archive union (both directions), and add a TDD test with a
  twice-superseded archived pair. Zero PR1 exposure (one archived record); becomes live here.
- [ ] **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).
- [ ] **Step 6** Run the full pytest suite: `PYTHONPATH=. python -m pytest scripts/tests -q`.
- [ ] **Step 7: Commit** (single squashed migration commit per batch is fine)

```bash
git add docs/decisions.md docs/decisions/
git -c core.hooksPath=/dev/null commit -m "feat(521): migrate decision corpus to lifecycle schema [decisions-edit]"
```

---

## 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).
- 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; re-scope `[decisions-edit]`, aggregate budget).

- [ ] **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 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 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:

```yaml
      - name: Validate decision lifecycle
        run: |
          base_ref="${{ ... existing base ref expr ... }}"
          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
```

  (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 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' .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): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header rewrite"
```

---

## Task 8: Startup / kickoff rewrite (#520)

**Files:**
- Modify: `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`,
  `scripts/select-queue.sh`.

- [ ] **Step 1** `docs/README.md`: replace the mandatory 1–10 reading order with a compact
  **task-signal → minimal sections** map (e.g. "API change → api-conventions §checklist +
  endpoint-index"; "scheduling → domain-model + decisions catalog rows keyed `sched.*`"; "startup/queue
  → handoff kickoff + select-queue.sh"). Add a pointer: decisions active view =
  `docs/decisions/README.md` (catalog), archive = `docs/decisions/archive/`.
- [ ] **Step 2** `docs/handoffs/chicorytv-issue-queue.md`: rewrite the STANDING KICKOFF around the
  two concurrent tracks (orientation ‖ `select-queue.sh 5`); named-issue path skips selection → focused
  retrieval; move the pre-script "re-derive the contested tier by hand" selector lore into a clearly
  labeled **"Archived — pre-script selector history (do not follow)"** section; reduce #237 to a single
  archival line marked `<!-- archival:237 -->`. Add the `## Closing record` template as the session-end
  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. 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`), using the **concrete conventions agreed
  with server-management#642** (the sole Gitea→MemPalace exporter):
  - **MemPalace = candidate discovery only**, retrieved at a bounded `k`; every passage **verified
    against its cited Markdown/Gitea source** before use.
  - **Default wing = `ErsatzTV-Decisions`** (active `docs/decisions.md` + `docs/decisions/*.md` +
    catalog). History wings `ErsatzTV-Decisions-Archive` (`docs/decisions/archive/**`) and
    `Gitea-ErsatzTV` (issues/comments incl. #520 closing records) are touched **only** when the
    question is explicitly "what did the rule *used to be*." "What is the current rule for X" never
    touches the history wings.
  - **Exact-search fallback** (MemPalace stale/down): `docs/decisions/README.md` catalog first, then
    ``rg '^`key: <dotted.key>`' docs/decisions/`` in the checkout. MemPalace is never the authority or
    the fallback.
  - **Staleness bounds** (so agents know when to distrust a hit): webhook re-mine in seconds; hourly
    reconcile; weekly full sweep catches file moves/deletes. Worst case before a supersession takes
    effect in retrieval: ~1h, or ~1 week if the webhook is down and only the move happened. #642 alerts
    above 5400s. When in doubt, fall back to exact search.
  - **never** derive live queue state from MemPalace, #237, or historical comments.
  - **Altitude / precedence:** `docs/decisions.md` (+ topic files) is **normative/current** ("the
    rule is X") — the `ErsatzTV-Decisions` wing, authority for "what is the current rule." Gitea
    issues are **evidentiary/historical** ("we worked X on date D; spec, discussion, outcome") — the
    `Gitea-ErsatzTV` wing, authority for "how was this handled / what was the context." A decision
    record points to its issue; the issue carries the provenance the record compresses. Same fact,
    two altitudes — canonical Markdown wins for "current."
  - **Issues in MemPalace are for DISCOVERY, not queue state.** Encounter a problem → search
    MemPalace → find relevant docs AND relevant open issue(s): this **avoids filing duplicates** and
    **surfaces bundles** (related issues to pick up together — better than manual cross-reference
    comments). But a MemPalace issue hit is a *candidate*: open/closed/claimed/blocked/priority flip
    constantly and MemPalace lags (seconds–1h, up to a week if the webhook is down), so a hit may be
    a since-closed issue shown open, or miss a just-filed one. **Always re-confirm live state in
    Gitea (`select-queue.sh`) before acting.** Same "discover in MemPalace, verify canonical" pattern
    as decision records — never treat a MemPalace issue hit as current queue state.
  - **What to mine per issue (recommendation to server-management#642):**
    - **Open issue → the BODY** (problem/task statement) — the discovery surface for dedup +
      bundle-clustering; an open issue has no closing record yet.
    - **Closed issue → body + the `## Closing record`** (how it was resolved).
    - **Ephemeral comments** (claim / progress / `Review-verdict:`) → skip or de-weight both ways —
      they're **process exhaust**, the same noise #237 concentrated, just distributed across issues.
    The `## Closing record` (Outcome / Root cause / Decisions changed / Reusable knowledge /
    Verification / Deferred / Docs updated) is the one structured per-issue summary meant for
    retrieval + export; the raw comment thread must never become the knowledge store.
  - **Altitude holds for issues too:** issue bodies/closing-records are **evidentiary** (the
    `Gitea-ErsatzTV` wing — "how/why it was worked"); `docs/decisions.md` is **normative** (the
    current rule). A decision points to its issue; the issue carries the provenance. Canonical
    Markdown wins for "current rule"; the issue wins for "context + is there prior/related work."
  - **The four load-bearing orientation bullets (the #642 seam — carry VERBATIM into the kickoff +
    `docs/README.md`; #642's 283-doc export is retrievable-but-unreached without them):**
    1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`;
       discover via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/
       retired). **Resolve by topic/key, never by chasing a file path.**
    2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative
       that may be stale; it never overrides current Markdown.
    3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical
       issue comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a
       live pointer.** Find the current rule via the catalog / active wing **by concept**; do not
       treat the named path as current. (Why it's safe: still-current → in the active wing, breadcrumb
       resolves; superseded → the active wing returns the *successor* and a literal follow lands on a
       record that announces its own `status: superseded`; retired → the active wing returns nothing,
       which is itself the signal. The validator-enforced move-to-`archive/` is what prevents the
       catastrophic "superseded rule read as current" case.)
    4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then
       ``rg '^`key: <dotted.key>`' docs/decisions/``. MemPalace is never authority nor sole fallback.
  - **Graceful degradation (calibration, not a correctness cliff):** even an under-oriented agent that
    literally greps `docs/decisions.md` post-#521 gets valid-but-*incomplete* results (misses topic
    files + archived records), never *wrong* ones — that file holds only active records. So this is a
    completeness/latency risk during rollout, not a correctness cliff. (#642 additionally strengthens
    each exported doc's authority banner to name the catalog/wing and "don't treat file paths below as
    current" — defense-in-depth for the skipped-orientation case.)
  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** 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 \
        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]"
```

---

## Task 9: Retrieval evaluation bank

**Files:**
- Create: `docs/decisions/retrieval-eval.md`

- [ ] **Step 1** Write ~10 questions across the required classes (paraphrased task→decision; exact
  code/path lookup; active-vs-superseded; retired feature; rationale/rejected-alternative; and at least
  one "convention already implemented — do not reimplement"). Each question lists the **expected active
  record key + citation**. Example rows:

```markdown
## Retrieval eval — active-record selection

1. "Where do REST response DTOs live and why?" → key `api.response-dtos` (docs/decisions.md).
2. "Should I add a service layer over the MediatR handlers?" → key `api.no-service-layer` (already
   decided NO — prevents reimplementation).
3. "Is #390's small-lane CI move still in effect?" → NO; superseded by `ci.runner-placement`
   (active). Selecting the #390 archive record as current is a FAILURE.
```

- [ ] **Step 2: Commit**

```bash
git add docs/decisions/retrieval-eval.md
git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question bank (active-record selection)"
```

---

## Task 10: Verification — cold-agent sims + adversarial review

- [ ] **Step 1 (deterministic)** From the worktree root run all gates green:
  `PYTHONPATH=. python -m pytest scripts/tests -q`;
  `PYTHONPATH=. python scripts/decisions_validate.py` (0 legacy-unmigrated);
  `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 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.
- [ ] **Step 4 (adversarial review)** Cold-context review-only agent (cross-model/Fable if available)
  over the whole diff: focus on the guard rework (does the lifecycle validator actually preserve the
  no-silent-delete spirit? any way to delete rationale and pass?), the no-vanish diff logic
  (fail-open holes), and #520 doc consistency. Fold fixes.
- [ ] **Step 5 (BOM/format)** No `.cs` touched; skip the BOM sweep. Confirm no stray CRLF/BOM in new
  scripts: `for f in scripts/decisions_lib.py scripts/decisions_validate.py scripts/build_decisions_catalog.py; do head -c3 "$f" | xxd -p | grep -q '^efbbbf' && echo "BOM: $f"; done` (expect no output).
- [ ] **Step 6** Push once (batched), open PR `fixes #520 #521`, arm CI monitor at PR-open.
- [ ] **Step 7 (notify server-management#642 — the MemPalace exporter)** #642 is the sole
  Gitea→MemPalace ingest and holds (mines nothing) until ErsatzTV signals readiness. The definitive
  ready signal is **the `decisions-validate` CI job green on `main` with 0 legacy-unmigrated** (which
  also guarantees every record has `key:`/`status:` and all superseded/retired records are under
  `docs/decisions/archive/` — the wing boundary #642 keys off). PR2 merges atomically, so main never
  shows a partial state. On merge, post a `#642` comment: "ready to mine — decisions-validate green on
  \<main-sha\>". Do NOT rely on `archive/` merely appearing; the green-validator sha is the go signal.
  (Design note: supersession EXTRACTS a single record from a multi-record topic file into `archive/`,
  leaving that file's other active records in place — do not repeat metadata per section; the wing
  boundary is the guarantee, so chunks 2..n of a record need no inline status.)

---

## Docs updated in this PR (checklist)

- `docs/README.md` (map), `docs/decisions.md` (header), `docs/decisions/README.md` (generated
  catalog), `docs/ci-cd.md` (release ritual), `CLAUDE.md` (docs-first + closing record),
  `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
  catalog (Task 3), topic/archive responsibilities (Task 6/7), per-record identity (Task 6), prose
  reversals reconciled (Task 5), validator (Task 2), aggregate budget (Task 2 `_budget_ok`), release
  ritual archives classified history (Task 7), migration complete-or-bounded (Task 6), retrieval
  benchmark (Task 9 + Task 10 Step 3), startup points at compact view (Task 8), docs same PR ✔.
- #520 done-when: start without #237 (Task 8), concurrent orientation+selector (Task 8), named-issue
  skips selection (Task 8), deterministic top-five (existing select-queue.sh, unchanged), README map
  (Task 8), MemPalace candidate+verify+exact-search fallback documented (Task 8 kickoff), #237 archival
  only (Task 8), pre-script lore archived (Task 8), structured closing comment (Task 8), cannot-regress
  check (Task 4 + Task 10), cold-agent sim (Task 10 Step 2), docs same PR ✔.
