feat(521): decision-lifecycle machinery — keyed records, validator, generated catalog (PR1 of 2) #526

Merged
timothy merged 17 commits from feat/520-521-decision-lifecycle into main 2026-07-21 11:08:52 +02:00
Owner

PR1 of 2 — decision-lifecycle machinery (#521)

First of two stacked PRs for the knowledge-retrieval arc (#520 + #521). This PR ships the machinery only; it does NOT close either issue — the full ~90-record migration + the #520 startup/kickoff rewrite land in PR2 (branched off this PR's merged head), which is what completes #520 and #521. So this is intentionally Refs, not fixes.

What this delivers

  • scripts/decisions_lib.py — parser for decision records + a visible metadata block (key/status/since/supersedes/superseded-by + Rule/Signals/Mechanics). An entry with no metadata is tolerated as legacy-unmigrated.
  • scripts/decisions_validate.py — the lifecycle guard that replaces the line-level append-only mechanic. Enforces: ≤1 active record per key; reciprocal supersession links resolve; no-vanish (a record leaving the active set must reappear under docs/decisions/archive/); rationale body-diff (a change to a record's prose without [decisions-edit] is blocked — closes the silent-rewrite hole); archive-placement (superseded/retired must be archived, active must not); metadata-demotion detection. Budget is a non-blocking warning. All git checks fail-open.
  • scripts/build_decisions_catalog.py — regenerates docs/decisions/README.md, the compact active catalog / task router (drift fails CI --check). Anchors match Gitea's real slugger (double-hyphen for —).
  • Guard rework: .claude/hooks/decisions-guard.sh → fail-open validator shim; .husky/commit-msg/pre-commit rewired; CI decisions-guard job → decisions lifecycle (validator + catalog check, setup-python, 1800-line floor removed). [decisions-edit] kept narrow (rationale-prose edits only), not retired.
  • Exemplar migrations + archive: ci.runner-placement (#406), ci.peak-anon-measurement (#412) active; the append-only→lifecycle supersession dogfooded (docs.decision-lifecycle active supersedes archived docs.append-only-guard); the old H9/H3 bundle split so the still-active root-screenshot guard (ci.root-screenshot-guard) stays in the active wing. docs/decisions/migration-map.md records it. #390/#411 confirmed prose-only.
  • Header/docs: docs/decisions.md header + docs/ci-cd.md release ritual rewritten to lifecycle framing; stale "append-only" instructions in CLAUDE.md/docs/README.md corrected.

MemPalace coordination (server-management#642)

Records front-load key/status for the ErsatzTV-Decisions wing; supersession is a physical move to archive/ (enforced by the validator), which is the wing boundary #642's exporter keys off. #642 mines nothing until a "validator green on " note after PR2.

Verification (all local, pre-push)

  • decisions_validate.py --base origin/main --head HEAD (the CI check) — exit 0, incl. after rebasing onto #518.
  • catalog --check in sync; scripts/tests 26 passed; ruff clean; husky token-free.
  • Per-task reviews + a whole-branch review (found & fixed a catalog-anchor BLOCKER verified against the repo's own 64 working anchors, plus archive-guard/demotion/reciprocal holes) + a non-vacuity re-review of the guard logic.

Notes

  • #511 (merged via #518 while this was in flight) is carried forward as a legacy-unmigrated record — it migrates when next touched.
  • Design docs (docs/superpowers/plans + specs) are included as the design record.
  • One non-blocking follow-up carried to PR2: extend the reciprocity check to archive↔archive pairs (zero exposure here; first appears in PR2's migration).

Refs #520 #521

## PR1 of 2 — decision-lifecycle machinery (#521) First of two stacked PRs for the knowledge-retrieval arc (#520 + #521). **This PR ships the machinery only; it does NOT close either issue** — the full ~90-record migration + the #520 startup/kickoff rewrite land in **PR2** (branched off this PR's merged head), which is what completes #520 and #521. So this is intentionally `Refs`, not `fixes`. ### What this delivers - **`scripts/decisions_lib.py`** — parser for decision records + a visible metadata block (`key`/`status`/`since`/`supersedes`/`superseded-by` + `Rule`/`Signals`/`Mechanics`). An entry with no metadata is tolerated as `legacy-unmigrated`. - **`scripts/decisions_validate.py`** — the lifecycle guard that **replaces the line-level append-only mechanic**. Enforces: ≤1 active record per key; reciprocal supersession links resolve; **no-vanish** (a record leaving the active set must reappear under `docs/decisions/archive/`); **rationale body-diff** (a change to a record's prose without `[decisions-edit]` is blocked — closes the silent-rewrite hole); archive-placement (superseded/retired must be archived, active must not); metadata-demotion detection. Budget is a non-blocking warning. All git checks fail-open. - **`scripts/build_decisions_catalog.py`** — regenerates `docs/decisions/README.md`, the compact active catalog / task router (drift fails CI `--check`). Anchors match Gitea's real slugger (double-hyphen for ` — `). - **Guard rework:** `.claude/hooks/decisions-guard.sh` → fail-open validator shim; `.husky/commit-msg`/`pre-commit` rewired; CI `decisions-guard` job → `decisions lifecycle` (validator + catalog check, `setup-python`, 1800-line floor removed). `[decisions-edit]` **kept narrow** (rationale-prose edits only), not retired. - **Exemplar migrations + archive:** `ci.runner-placement` (#406), `ci.peak-anon-measurement` (#412) active; the append-only→lifecycle supersession dogfooded (`docs.decision-lifecycle` active supersedes archived `docs.append-only-guard`); the old H9/H3 bundle **split** so the still-active root-screenshot guard (`ci.root-screenshot-guard`) stays in the active wing. `docs/decisions/migration-map.md` records it. #390/#411 confirmed prose-only. - **Header/docs:** `docs/decisions.md` header + `docs/ci-cd.md` release ritual rewritten to lifecycle framing; stale "append-only" instructions in `CLAUDE.md`/`docs/README.md` corrected. ### MemPalace coordination (server-management#642) Records front-load `key`/`status` for the `ErsatzTV-Decisions` wing; supersession is a physical move to `archive/` (enforced by the validator), which is the wing boundary #642's exporter keys off. #642 mines nothing until a "validator green on <main-sha>" note after PR2. ### Verification (all local, pre-push) - `decisions_validate.py --base origin/main --head HEAD` (the CI check) — exit 0, incl. after rebasing onto #518. - catalog `--check` in sync; `scripts/tests` 26 passed; ruff clean; husky token-free. - Per-task reviews + a whole-branch review (found & fixed a catalog-anchor BLOCKER verified against the repo's own 64 working anchors, plus archive-guard/demotion/reciprocal holes) + a non-vacuity re-review of the guard logic. ### Notes - `#511` (merged via #518 while this was in flight) is carried forward as a `legacy-unmigrated` record — it migrates when next touched. - Design docs (`docs/superpowers/plans` + `specs`) are included as the design record. - One non-blocking follow-up carried to PR2: extend the reciprocity check to archive↔archive pairs (zero exposure here; first appears in PR2's migration). Refs #520 #521
timothy added 17 commits 2026-07-21 03:01:35 +02:00
Design for the decision-knowledge lifecycle schema/catalog/validator (#521) and
the #237-retirement / parallel-startup kickoff rewrite (#520), as one arc.

Refs #520 #521

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs #520 #521

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs #520 #521

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds scripts/decisions_lib.py, the shared parser for ErsatzTV decision
records (docs/decisions.md + docs/decisions/*.md). Parses H2 sections
into Record dataclasses, distinguishing migrated records (visible
metadata block: key/status/since/supersedes/superseded-by + Rule/
Signals/Mechanics) from legacy-unmigrated ones with no metadata line.

scripts/ is now an importable package (scripts/__init__.py,
scripts/tests/__init__.py) so later tools can `import scripts.decisions_lib`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds scripts/decisions_validate.py: lifecycle invariant checks (unique
active key, key format, reciprocal supersession, removed-without-archive,
rationale-rewrite-without-token, catalog staleness, corpus budget) plus
git-diff helpers for merge-base-based CI checks. Deviates from the task
brief in one spot: REQUIRED_META narrowed to (key, status) — the brief's
(key, status, since, supersedes, superseded_by) makes its own
test_clean_corpus_passes fail, since since/supersedes/superseded_by
default to None on bare Record() instances built without going through
the markdown parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds scripts/build_decisions_catalog.py, which renders docs/decisions/README.md
as a compact table of only 'active' decision records (sorted by key), and its
test scripts/tests/test_build_catalog.py. Supports --check for CI drift
detection. No decision records are migrated yet, so the generated catalog is
currently empty (banner + header only) — expected at this stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_anchor() mapped each space/hyphen to its own '-' without collapsing runs,
so the standard heading separator " — " (space, em-dash, space) produced a
double hyphen in every generated anchor. Since nearly every real decision
record heading uses that separator, this made the catalog emit a dead link
for essentially every row. Fix: after building the char list, collapse
consecutive '-' into one and strip leading/trailing '-' via re.sub, matching
how Goldmark/GitHub/Gitea sluggers behave.

Also fixed main() writing an extra trailing newline (want already ends in
"\n", then "+ \n" appended a second one) so docs/decisions/README.md now
ends with exactly one trailing newline; --check still compares via .strip().

Added test_anchor_collapses_em_dash_and_keeps_underscore to pin the anchor
behavior against the reported iptv.base_url case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate #406 (ci.runner-placement) and #412 (ci.peak-anon-measurement) to the lifecycle
schema as exemplars; #390 and #411 confirmed prose-only (no standalone record). Dogfood the
append-only->lifecycle supersession: docs.append-only-guard moved to archive/, superseded by
new active docs.decision-lifecycle. Stand up docs/decisions/archive/ + migration-map.md.

Refs #521

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The archived docs.append-only-guard record bundled the genuinely-superseded
H9 (decisions.md append-only) with the independent, still-active H3
(root-screenshot pre-commit guard), relocating both into archive/ and
burying an active decision in the history wing. Split into two records:
docs.append-only-guard stays in archive/release-ci-governance.md,
status: superseded, H3 content removed and replaced with a forward
pointer; ci.root-screenshot-guard is a new status: active record in the
active docs/decisions/release-ci-governance.md, with the H3 rationale
paragraph lifted byte-identical from the original bundle. Updated
migration-map.md (new row + a "Migration conventions" note: never
relocate an active decision to archive/) and regenerated
docs/decisions/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the line-level append-only mechanic (ersatztv#303 H9) in favor of the lifecycle validator
built in Tasks 1-5. .claude/hooks/decisions-guard.sh is now a thin fail-open shim around
scripts/decisions_validate.py; .husky/pre-commit calls it for the structural (working-tree) checks,
.husky/commit-msg drops the old staged/[decisions-edit]-deletion block and keeps only the
Co-Authored-By check. The Gitea decisions-guard job is renamed "decisions lifecycle" and now runs
decisions_validate.py --base/--head (structural + body-diff + no-vanish) and
build_decisions_catalog.py --check (active catalog drift), with actions/setup-python@v5 added since
the bare `small` lane doesn't guarantee python3; the old 1800-line consolidation-floor step is
removed (replaced by the validator's aggregate active-corpus budget). docs/decisions.md's header is
rewritten from append-only to lifecycle framing (metadata schema, statuses, generated catalog,
archive, same-PR supersession); [decisions-edit] is re-scoped (not removed) to rationale-prose
edits/factual corrections only. docs/ci-cd.md's release ritual and hook/job descriptions are
rewritten to match.

Also fixes a pre-existing validator false-positive surfaced while sanity-checking against
origin/main: Task 6's #303 H9/H3 split (commit d09be57e) renamed the archived record's heading
away from the pre-split original, which the validator's heading-based relocation check reads as
"removed without an archive copy." Restored the archived heading to match the original text
(functionally unchanged — still status: superseded, same key) and updated the two prose
cross-references (migration-map.md, release-ci-governance.md) that pointed at the old anchor.

PR1 scope only (per brief): does NOT wire the kickoff-guard CI step or touch the
kickoff/README/select-queue docs — that's Task 8/PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(521): carry archive<->archive reciprocity gap into PR2 Task 6 (PR1 whole-branch review, non-blocking)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 1m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
46ea8f0e92
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy merged commit f4bebd77b8 into main 2026-07-21 11:08:52 +02:00
timothy deleted branch feat/520-521-decision-lifecycle 2026-07-21 11:08:52 +02:00
Sign in to join this conversation.