diff --git a/.claude/hooks/decisions-guard.sh b/.claude/hooks/decisions-guard.sh index 66ff68898..7b45edcec 100755 --- a/.claude/hooks/decisions-guard.sh +++ b/.claude/hooks/decisions-guard.sh @@ -1,54 +1,12 @@ #!/usr/bin/env bash -# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or -# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new -# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so -# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit: -# put the literal token [decisions-edit] in the commit message to override. -# -# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point -# is to catch the accidental rewrite-history case, never to wedge a legitimate commit. -# -# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final -# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1) -# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting. -# -# Modes: -# staged pre-commit/commit-msg — staged diff vs HEAD; trailer read from -# range CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs -set -euo pipefail - -FILE="docs/decisions.md" -mode="${1:-}" - -case "$mode" in - staged) - deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1) - msg=$(cat "${2:-/dev/null}" 2>/dev/null || true) - ;; - range) - base="${2:-}"; head="${3:-}" - [ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open - deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1) - msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true) - ;; - *) - exit 0 # unknown mode -> fail-open - ;; -esac - -# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard). -deleted="${deleted:-0}" -case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac -[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow - -# Explicit override for a documented factual fix. -if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then - exit 0 -fi - -{ - echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)." - echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries." - echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message." -} >&2 -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 diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 937ecaf02..5482d6124 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -726,12 +726,14 @@ jobs: echo "Parity-doc reminder: nothing to flag." fi - # BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or - # rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries - # the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg - # hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners. + # BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision- + # record lifecycle invariants (metadata schema, one active record per key, reciprocal + # supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record + # vanishing from the active set without an archive copy) and that the generated active catalog + # (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so + # local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners. decisions-guard: - name: decisions.md append-only + name: decisions lifecycle runs-on: small if: github.event_name == 'pull_request' steps: @@ -739,22 +741,17 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Enforce append-only + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Validate decision lifecycle run: | base_ref="${{ github.base_ref }}" git fetch --no-tags --depth=200 origin "$base_ref" || true - ./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD - - name: Consolidation-floor reminder (non-blocking) - run: | - # Consolidation is primarily a release step; this is the between-releases floor. The metric is - # the file's LINE COUNT — the context an agent actually burns reading the log — not entry count. - # Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's - # own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read. - n=$(wc -l < docs/decisions.md | tr -d ' ') - echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)." - if [ "${n:-0}" -gt 1800 ]; then - echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header." - fi + 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 # BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the # same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API diff --git a/.husky/commit-msg b/.husky/commit-msg index 08f6d06ef..189629eac 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -8,8 +8,3 @@ grep -q '^Co-Authored-By:' "$1" || { echo 'husky - commit message missing Co-Authored-By trailer' exit 1 } - -# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled -# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so -# the staged diff is what's being committed; the message file ($1) supplies the override token. -./.claude/hooks/decisions-guard.sh staged "$1" || exit 1 diff --git a/.husky/pre-commit b/.husky/pre-commit index 8abf993b8..a72e62d0a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,12 @@ cd web && npx lint-staged || exit 1 cd .. +# ersatztv#521 — decision-record lifecycle structural validator (replaces the old H9 append-only +# line guard). Runs the same validator the CI `decisions lifecycle` job uses, over the working +# tree (no base/head here, so only structural checks run; the body-diff/no-vanish checks run in +# CI where a base ref exists). Fail-open shim — see .claude/hooks/decisions-guard.sh. +./.claude/hooks/decisions-guard.sh || exit 1 + # H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with # .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets. root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true) diff --git a/CLAUDE.md b/CLAUDE.md index 8101fc806..466fe26f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev . | Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` | | Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` | | Change a SPA screen convention | `docs/spa-conventions.md` | - | Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc | + | Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc | | Add / remove / retitle a doc | `docs/README.md` index | The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check. diff --git a/docs/README.md b/docs/README.md index 1e21559a8..6eba8dcd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,9 +22,9 @@ Read in this order at session start: 8. **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover: the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent (or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory. -9. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing - convention. Start from its **Index**, which links the four topic files under `docs/decisions/` - (large same-topic clusters) and lists the remaining in-file entries. +9. **`docs/decisions.md`** — lifecycle "why" log: active records plus retired/superseded ones + relocated to `docs/decisions/archive/`. Check here before challenging an existing convention; + start from the generated active catalog, `docs/decisions/README.md`. 10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management. Also present in `docs/`: diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 6c7d8799e..63c2aa381 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -39,11 +39,26 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t | `v26.10.0` | Auto-Tune channel workflow (#69) + weighted content distribution (#70); scheduling refactors, health-check remediation UX (#164), HLS cold-start instrumentation (#350), security hardening (#293/#376/#308). | | `v26.11.0` | **QSV profiles decode via VA-API** — `QsvPreferNativeDecoder`, default **on**, fixes ~50% channel cold-start failures on Intel (#498); unified logo/on-screen bug via a shared watermark preset (#67). Media-scanner resilience: Jellyfin mixed-content libraries (#489), music-video scan correctness (#488/#494/#497), remote-stream probing before ffmpeg (#473/#480); weighted-distribution SPA (#404). **First release deployed to `jazz`** (server-management#633). | -**Before cutting a release — consolidate `docs/decisions.md`.** The log is append-only between releases -(ersatztv#303 H9), so a release boundary is where superseded entries get pruned/merged and the Index -refreshed. Fold and drop any entry marked `> **Superseded …**`, then commit with `[decisions-edit]` in -the message (the append-only guard blocks history edits otherwise). Mark-and-keep during the arc, -consolidate here — or sooner if the `decisions-guard` job's 1800-line consolidation-floor warning fires. +**Before cutting a release — sweep `docs/decisions.md` + `docs/decisions/`** (ersatztv#521, supersedes +the ersatztv#303 H9 append-only ritual). Supersession/retirement is now a same-PR act (add the new +active record, relocate the predecessor to `docs/decisions/archive/` with reciprocal +`supersedes`/`superseded-by` links), not a release-boundary batch job — most of the old "consolidate" +step is now continuous. The release boundary is instead where you: +1. Run `PYTHONPATH=. python3 scripts/decisions_validate.py` — confirms lifecycle metadata is + well-formed and every `supersedes`/`superseded-by` link resolves both ways. +2. Confirm every record already classified `superseded`/`retired` actually lives under + `docs/decisions/archive/` (the validator fails this, but eyeball it at the boundary too). +3. Regenerate the active catalog: `PYTHONPATH=. python3 scripts/build_decisions_catalog.py` and + commit any drift. +4. Check the aggregate active-corpus budget (`decisions_validate.py --budget `, default 4200 + lines across `docs/decisions.md` + topic files + the catalog — replaces the old single-file + 1800-line floor). Going over budget is a **non-blocking warning** (`::warning::` to stderr, not a + validator error) — a ratchet/reminder to migrate more `legacy-unmigrated` records or extract a new + topic file, not a release gate. +5. Report the remaining `legacy-unmigrated` count (the validator prints it as a `::notice::`) so the + backlog is visible, even though it isn't required to hit zero before a release. +A genuine rationale-prose rewrite still needs `[decisions-edit]` in the commit message (see the +`decisions.md` header) — routine lifecycle metadata writes above do not. **Cutting a release:** keep build and promotion as two explicit phases (#335): @@ -511,7 +526,7 @@ matrix. A false skip could ship an under-validated image, so every ambiguous cas *fast-forward-equivalent* merge: main did not advance since the PR's last green run **and** the PR head was not rebased at merge time. Two routine patterns defeat it in this repo: (1) under parallel merges main usually advances; and (2) — the bigger one — the standard workflow **rebases a PR -before merging** to resolve the append-only `docs/decisions.md` conflict (see MEMORY: the +before merging** to resolve the `docs/decisions.md` lifecycle conflict (see MEMORY: the "decisions.md conflict treadmill"), which mints a new head SHA whose tree was never itself CI-validated, so the tree-match check correctly declines. So the skip is a genuine but *occasional* win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by @@ -530,20 +545,22 @@ thus no `actions/cache`), so it can't hit the cache-save hangs seen on the VM-12 (domain-model, spa-conventions) — those stay on the author. (The API contract is mechanized by the blocking `api-docs` job, and `docs/decisions.md` by the blocking `decisions-guard` job below.) -### `decisions-guard` job (blocking, PR-only) +### `decisions-guard` job (`decisions lifecycle`, blocking, PR-only) -Enforces the `docs/decisions.md` append-only convention (ersatztv#303 H9): fails a PR whose -merge-base diff **deletes or modifies** any existing line of that file, unless a commit in the range -carries the `[decisions-edit]` token (for a factual fix or a documented supersession — see the -`decisions.md` header). Pure insertions (a normal new entry: TOC line + appended block) pass. The job -also emits a **non-blocking** consolidation nudge once the file exceeds **1800 lines** (the read-cost -floor — one default agent Read caps at 2000 lines), so append-only can't outgrow what agents read. It runs -the same `.claude/hooks/decisions-guard.sh` the Husky `commit-msg` hook uses, so the *detection logic* -is shared and can't drift. Granularity differs, deliberately: Husky checks **each commit** (`staged` -mode, that commit's own message must carry the token); CI checks the **PR-wide** net diff (`range` mode, -accepts the token in *any* commit of the range). The local hook is therefore the stricter, primary -gate; CI is the backstop for direct pushes or bypassed hooks. Like `docs-reminder`, it's a -seconds-long `git diff` with no dotnet/node setup (`runs-on: small`). +Enforces decision-record lifecycle invariants (ersatztv#521, supersedes the ersatztv#303 H9 +append-only mechanic): well-formed 5-field metadata, exactly one `active` record per `key`, +reciprocal `supersedes`/`superseded-by` links, no record vanishing from the active set without an +archive copy, no rationale-prose rewrite without the `[decisions-edit]` token in the commit range, +and the generated active catalog (`docs/decisions/README.md`) in sync with source. Two steps: +`scripts/decisions_validate.py --base origin/ --head HEAD` (the merge-base diff checks, which +need a base/head range — CI-only) and `scripts/build_decisions_catalog.py --check` (catalog drift). +The **same validator** backs the Husky `pre-commit` hook (`.claude/hooks/decisions-guard.sh`, no +base/head there — structural checks only, over the working tree), so local and CI enforcement can't +drift on the rules that don't need a range. `python3` isn't guaranteed on the bare `small` lane, so +the job adds `actions/setup-python@v5` before invoking it; that install is lightweight (no +compiler/docker build), so it doesn't violate the "small is git-only" lane rule. Like +`docs-reminder`, otherwise a seconds-long `git diff` + parse with no dotnet/node setup +(`runs-on: small`). ## CI toolchain image (`docker/ci/Dockerfile`, `.gitea/workflows/ci-image.yml`) @@ -911,20 +928,24 @@ entirely in `web/`), the wiring is: files (~6-7s wall in practice, dominated by the workspace load); (c) **H3** (ersatztv#303) — refuses a staged **root-level `*.png`** (`git diff --cached --name-only | grep -E '^[^/]+\.png$'`), belt-and-suspenders with the `.gitignore` screenshot rule so a forced `git add -f` still can't land - a review/debug screenshot at the repo root. Nested `*.png` (real assets) pass. + a review/debug screenshot at the repo root. Nested `*.png` (real assets) pass; (d) **decision + lifecycle validator** (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic) — runs + `.claude/hooks/decisions-guard.sh` (no args; a fail-open shim around + `scripts/decisions_validate.py`), the structural checks over the working tree (metadata + well-formedness, one active record per key, reciprocal links). It has no base/head here, so the + body-diff/no-vanish checks it also knows about are skipped locally and only run in the CI + `decisions lifecycle` job, which has a PR base to diff against. 2. **`pre-push`** — CI-parity gate: `cd web && npm run check:api && npm run lint && npm run typecheck && npm run build`. `check:api` guards generated-OpenAPI drift (`ErsatzTV/wwwroot/openapi/v1.json` → `web/src/api/generated/v1.d.ts`); the full lint/typecheck/build catch a staged change that breaks an *unstaged* file (lint-staged only sees staged files). Any failure blocks the push. -3. **`commit-msg`** — (a) enforces the CLAUDE.md protocol: the message must carry a +3. **`commit-msg`** — enforces the CLAUDE.md protocol: the message must carry a `Co-Authored-By:` trailer, else the commit is rejected (merge commits are exempt, detected - via `git rev-parse --verify MERGE_HEAD`); (b) **H9** (ersatztv#303) — runs - `.claude/hooks/decisions-guard.sh staged "$1"`, which blocks the commit if it deletes/modifies - an existing line of `docs/decisions.md` unless the message carries the `[decisions-edit]` token. - Append-only enforcement; see `docs/decisions.md` header for the supersession/consolidation rules. - The same script backs the blocking **`decisions-guard`** CI job (`range` mode over the PR's - merge-base diff) so local and CI enforcement can't drift. + via `git rev-parse --verify MERGE_HEAD`). The decision-lifecycle check lives in `pre-commit` + (above), not here — `[decisions-edit]` is read from the commit message, but only by the CI + `decisions lifecycle` job's body-diff step (`range` mode over the PR's merge-base diff), which is + the only place a base/head range exists to diff against. - **Worktree/subdir gotcha**: git exports `GIT_DIR` (and friends) while running hooks. In a worktree or any subdir, an explicit `GIT_DIR` makes nested `git` commands mislocate the diff --git a/docs/decisions.md b/docs/decisions.md index c36b173fc..41f85c7da 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,26 +1,51 @@ -# Decisions — append-only log +# Decisions — lifecycle log Purpose: why the codebase does what it does, so agents don't "fix" an established convention or -relitigate a settled call. Append new entries at the bottom in date order (plus a TOC line in the -Index); **update this doc in the same PR that changes any fact below** (or that establishes a new -convention worth recording). +relitigate a settled call. **Update this doc (or a topic file under `docs/decisions/`) in the same +PR that changes any fact below** (or that establishes a new convention worth recording). -**Append-only, enforced (ersatztv#303 H9).** A commit or PR that *deletes or modifies* an existing -line is blocked by the Husky `commit-msg` hook and the CI `decisions-guard` job; insertions anywhere -are always allowed. The block is lifted only by the **`[decisions-edit]`** token in the commit -message, for the two legitimate reasons to touch history: -- **Fix a factual error** in a past entry. -- **Supersede a reversed decision** — add the new dated entry, then prepend a - `> **Superseded YYYY-MM by .**` banner to the old entry and tag its Index line - `(superseded)`. Keep the old entry — *why we changed our mind* is the point; never silently rewrite it. +**Lifecycle, not append-only (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic).** +A migrated record is an H2 whose first non-blank content line is a metadata block: -**Consolidation** (prune/merge superseded entries, refresh the Index) is primarily a step in the -release checklist (`docs/ci-cd.md` → Versioning & releases), done at each release/milestone with -`[decisions-edit]` — not ad hoc mid-arc. A **size floor** backstops it between releases: the -`decisions-guard` CI job warns (non-blocking) once this file exceeds **1800 lines** — the point where -it no longer fits one default 2000-line agent Read — so append-only can't grow past what an agent can -read in a pass. The metric is the file's read cost (line count), not the entry count. Together these -keep append-only from accreting stale, contradictory, or unreadably-large history. +> YYYY-MM-DD — Title … (#issue) +> `` `key: area.topic` · `status: active` · `since: YYYY-MM-DD` · `supersedes: none` · `superseded-by: none` `` +> **Rule:** one-line current rule. +> **Signals:** concept · paths: a/b.yml · issues: #issue +> **Mechanics:** docs/some-doc.md → section +> \ + +(Blockquoted here so the illustrative `key`/heading don't parse as a real record; the `## 2026-07-17 +— No persistent compiler servers …` entry below is a live example.) Five metadata fields: `key` +(dotted, e.g. `ci.runner-placement`), `status`, `since`, `supersedes`, `superseded-by`. `status` is +one of: +- **`active`** — the current, authoritative record for its `key`. Exactly one active record per key. +- **`superseded`** — reversed by a newer record; relocated to `docs/decisions/archive/` with + `superseded-by: ` pointing forward, and the successor's `supersedes` pointing back. +- **`retired`** — no longer applicable (not reversed, just obsolete); also lives in the archive. + +(An H2 with no metadata block is `legacy-unmigrated` — not yet ported to this schema. The validator +tolerates these and reports a count; they trend to zero over time, not required to hit zero at once.) + +**Supersession is same-PR, not a later consolidation pass**: add the new active record, then relocate +the predecessor's file (or section) into `docs/decisions/archive/` with the metadata above rewritten +to `status: superseded` (or `retired`) and the reciprocal `supersedes`/`superseded-by` links filled +in on both records. Never silently rewrite a record's rationale prose in place. + +**Generated active catalog**: `docs/decisions/README.md` is generated from every `active` record +(this file + the topic files, excluding the archive) via `scripts/build_decisions_catalog.py` — run +it after any status change; CI's `decisions lifecycle` job fails on drift (`--check`). + +**Enforcement**: `scripts/decisions_validate.py` checks metadata well-formedness, one-active-record- +per-key, reciprocal links, that no record vanishes from the active set without an archive copy, that +the active catalog is in sync, and an aggregate active-corpus line budget (replaces the old 1800-line +floor on this single file). The Husky `pre-commit` hook runs the structural checks over the working +tree; the CI `decisions lifecycle` job additionally runs the body-diff check with `--base`/`--head`. + +**`[decisions-edit]`** is now narrow: it is required ONLY when a commit changes the **rationale +prose** of a surviving or archived record — a factual correction to already-written history. Routine +lifecycle writes (adding a new active record, relocating a superseded/retired record to the archive, +updating metadata fields, regenerating the catalog) are token-free; the validator proves they're +legitimate structurally instead of gating on the token. --- @@ -104,6 +129,7 @@ in-file entries. - [2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)](#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) - [2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)](#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) - [2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)](#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) +- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) --- @@ -1248,6 +1274,10 @@ weights) and SPA child #386 remain open under the #383 milestone. --- ## 2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406) +`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none` +**Rule:** No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). +**Signals:** CI memory/swap thrash · paths: `.gitea/workflows/*.yml`, `docker/Dockerfile` · issues: #406, #390 (prose-reversed, no standalone record), server-management#604, server-management#570 +**Mechanics:** `docs/ci-cd.md` → CI build memory; `scripts/ci-peak-anon.sh` Three CI changes, all downstream of one incident: on 2026-07-17 bumblebee (the **prod media** Docker host, 25 GiB / 12 cores) hit load **340** with **21 GiB swapped** and ~238 MiB free, taking prod @@ -2059,6 +2089,10 @@ The blocking `format` CI job (and the matching Husky pre-commit hook) verify cha server-management capacity call — deliberately out of scope here. ## 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412) +`key: ci.peak-anon-measurement` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none` +**Rule:** The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. +**Signals:** CI memory measurement · paths: `scripts/ci-peak-anon.sh`, `.gitea/workflows/*.yml` · issues: #412, #411 (prose-only predecessor, no standalone record — its `memory.peak`-headline approach is superseded by this record) +**Mechanics:** `scripts/ci-peak-anon.sh` header; `docs/ci-cd.md` → CI build memory **Decision.** The `test` job's memory instrument (added in #411) now reports a **sampled high-water mark of the cgroup's `anon` memory** as the headline figure, produced by `scripts/ci-peak-anon.sh` @@ -2727,3 +2761,52 @@ inert**: the elements' pre-existing blanket `catch (Exception)` swallows both on shutdown mid-tune logs a spurious per-element warning. The filter is kept because it makes the fetcher correct on its own terms and the log message names the real cause; making the elements re-throw cancellation is a separate, pre-existing concern. + +## 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521) +`key: docs.decision-lifecycle` · `status: active` · `since: 2026-07-21` · `supersedes: docs.append-only-guard@2026-07-12` · `superseded-by: none` +**Rule:** every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. +**Signals:** decision lifecycle, supersession, archive · paths: `scripts/decisions_lib.py`, `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `docs/decisions/archive/` · issues: #520, #521 +**Mechanics:** `scripts/decisions_validate.py` (`PYTHONPATH=. python3 scripts/decisions_validate.py`); catalog regen `scripts/build_decisions_catalog.py`; `docs/decisions/migration-map.md` + +**This replaces `docs.append-only-guard` (ersatztv#303 H9), not just extends it.** The old guard treated +`docs/decisions.md` as a single append-only text file: any line-delete was blocked by a Husky +`commit-msg` hook + CI job, and a "supersession" was a hand-written `> **Superseded …**` banner +prepended to the old entry plus an `(superseded)` Index tag — prose conventions an agent had to +remember and apply correctly by hand, with no machine check that the banner, the tag, and the new +entry's back-reference actually agreed with each other. That is exactly the failure mode this arc +(#520/#521) set out to fix: state should be derived and checked, not asserted in prose (the same +throughline as #303 H6's `## Done-when`). + +**The new model.** Every decision record — whether still in force or not — gets a `key` (dotted, +stable identity independent of its heading text or which file it lives in), a `status` +(`active`/`superseded`/`retired`/`legacy-unmigrated` for anything not yet carrying the schema), a +`since` date, and a reciprocal `supersedes`/`superseded-by` pair of keys. `scripts/decisions_lib.py` +parses the schema; `scripts/decisions_validate.py` enforces: at most one `active` record per key; +`superseded`/`retired` records MUST live under `docs/decisions/archive/`, never in an active file; +an `active` record MUST NOT live under `archive/`; every `supersedes`/`superseded-by` key must +resolve to a record that actually exists (active or archived); and — replacing the append-only +guard's line-delete detection — a record's rationale prose (the schema strips only the contiguous +metadata block before comparing) must not change between a PR's base and head unless the commit +carries `[decisions-edit]`, and a record that disappears from the active set without a matching +archive copy is flagged as an unlogged removal. `scripts/build_decisions_catalog.py` regenerates +`docs/decisions/README.md`, a compact router listing only `active` records by key, so an agent +doesn't have to read the full chronological log (or the archive) to find the current rule. + +**Why this is a strictly better enforcement of the same spirit, not a loosening.** The old guard's +actual invariant was never "no line may change" — it was "history is never silently rewritten to +erase *why we changed our mind*." A hand-prepended banner satisfied that invariant by convention; a +validator that checks reciprocal keys, archive placement, and rationale-prose stability satisfies it +by construction, and additionally catches classes of mistake the old guard could not (a supersession +banner added without moving the entry out of the active read-path; a `superseded-by` reference to a +key that doesn't exist; two `active` records quietly claiming the same key after a copy-paste). +`[decisions-edit]` is kept, narrowed to its one remaining legitimate use — an actual rationale-prose +edit (fixing a factual error) — since routine lifecycle moves (a status flip, an archive relocation) +are now token-free and machine-checked instead. + +**This PR is the first to exercise the new regime, on itself.** `docs.append-only-guard` (the H9 half +of #303's combined H9/H3 entry; H3's root-screenshot guard is untouched and stays active) is marked +`status: superseded` and relocated to `docs/decisions/archive/release-ci-governance.md`, with this +record as its `superseded-by` and this record's `supersedes` pointing back — the same-PR supersession +pattern the new rule itself mandates for every future reversal. `docs/decisions/migration-map.md` +tracks the legacy-heading → key → status → location mapping as the rest of the log migrates +(ersatztv#520/#521, ongoing — most existing headings remain `legacy-unmigrated` until touched). diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 000000000..a5735bca0 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,14 @@ + + +# 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 | +| --- | --- | --- | --- | +| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) | +| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) | +| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) | +| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) | diff --git a/docs/decisions/archive/README.md b/docs/decisions/archive/README.md new file mode 100644 index 000000000..174abc774 --- /dev/null +++ b/docs/decisions/archive/README.md @@ -0,0 +1,22 @@ +# Archive — superseded / retired decision records + +This directory holds decision records whose `status` is `superseded` or `retired`. They are kept +verbatim (rationale prose untouched — see `docs/decisions.md` header and `scripts/decisions_lib.py`) +for history: *why we changed our mind* is the point, never silently rewritten. + +They are **out of the active startup path**: `scripts/decisions_lib.py active_files()` / +`all_active_records()` do not glob this directory, `docs/decisions/README.md` (the active catalog) +never lists a record from here, and an agent doing task-router discovery should not need to read +this directory to find the *current* rule — follow a record's `superseded-by` key to the active +successor instead. + +The lifecycle validator (`scripts/decisions_validate.py`) still enforces invariants here: +- a `superseded`/`retired` record MUST live under this directory, never in an active file; +- an `active` record MUST NOT live under this directory; +- `supersedes`/`superseded-by` keys must resolve reciprocally to a record in the active set OR here; +- a record moved here must not have its rationale prose changed in the same commit (unless the + commit message carries the `[decisions-edit]` token, reserved for genuine rationale edits). + +One file per topic cluster (e.g. `ci.md`, `release-ci-governance.md`), mirroring the active +`docs/decisions/*.md` topic-file split. See `docs/decisions/migration-map.md` for the legacy +heading → key → status → location mapping produced during the #521 lifecycle migration. diff --git a/docs/decisions/archive/release-ci-governance.md b/docs/decisions/archive/release-ci-governance.md new file mode 100644 index 000000000..d4b2d08e5 --- /dev/null +++ b/docs/decisions/archive/release-ci-governance.md @@ -0,0 +1,43 @@ +# Archived — release/CI/merge-governance records superseded by the #521 lifecycle schema + +Records relocated verbatim (rationale prose unchanged) from `docs/decisions/release-ci-governance.md` +during the #521 decision-lifecycle migration. See `docs/decisions/archive/README.md` for what this +directory is and `docs/decisions/migration-map.md` for the full mapping. + +- **`docs.append-only-guard`** (below) — the `docs/decisions.md`-is-append-only-by-construction half + of #303 H9/H3. Superseded by `docs.decision-lifecycle` (active, in `docs/decisions.md`) — the + lifecycle validator (`scripts/decisions_validate.py`) that this very PR introduces replaces + line-level append-only enforcement with record-level lifecycle checks. The companion H3 + root-screenshot guard was split out (#521) into its own still-active record, + `ci.root-screenshot-guard`, in `docs/decisions/release-ci-governance.md` — it is not covered by + this supersession and was never archived. + +--- + +## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) +`key: docs.append-only-guard` · `status: superseded` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: docs.decision-lifecycle@2026-07-21` +**Rule:** (superseded) `docs/decisions.md` is append-only, enforced by hook + CI. +**Signals:** decisions-guard hook · paths: `.claude/hooks/decisions-guard.sh`, `.husky/commit-msg` · issues: #303 (H9) +**Mechanics:** superseded by `scripts/decisions_validate.py` (ersatztv#521); see `docs/decisions.md` → `docs.decision-lifecycle` + +**This log is append-only by construction, not just by convention.** A commit or PR that deletes or +modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook +(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same +script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook +gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff +(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the +push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line +near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is +`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-` +prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the +commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision +(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line +`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded +entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a +non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the +log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or +unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal, +consolidate at each milestone, size-floor backstop). + +The companion H3 root-screenshot guard was split out during the #521 migration to the active record +`ci.root-screenshot-guard` in `docs/decisions/release-ci-governance.md`. diff --git a/docs/decisions/migration-map.md b/docs/decisions/migration-map.md new file mode 100644 index 000000000..032ce97dd --- /dev/null +++ b/docs/decisions/migration-map.md @@ -0,0 +1,58 @@ +# 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. Most headings in `docs/decisions.md` and the +`docs/decisions/*.md` topic files are still `status: legacy-unmigrated` (no metadata block yet) — this +table only covers records touched so far. `PYTHONPATH=. python3 scripts/decisions_validate.py` prints +the running legacy-unmigrated count; it must trend to 0 over the arc, not drop to 0 in one PR. + +| Legacy heading | Key | Status | Location | +| --- | --- | --- | --- | +| 2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406) | `ci.runner-placement` | active | `docs/decisions.md` | +| 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412) | `ci.peak-anon-measurement` | active | `docs/decisions.md` | +| 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) — heading kept identical to the pre-split original (only the body/metadata were split) so the lifecycle validator's relocation check matches it | `docs.append-only-guard` | superseded (`superseded-by: docs.decision-lifecycle@2026-07-21`) | `docs/decisions/archive/release-ci-governance.md` | +| 2026-07-12 — Root-screenshot guard: pre-commit refuses root-level `*.png` (#303 H3) — split from the original H9/H3 bundle | `ci.root-screenshot-guard` | active | `docs/decisions/release-ci-governance.md` | +| 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521) | `docs.decision-lifecycle` | active (`supersedes: docs.append-only-guard@2026-07-12`) | `docs/decisions.md` | + +## Migration conventions + +A legacy `##` entry that bundles an active sub-decision with a superseded/retired one is SPLIT during +migration — the superseded half moves to `archive/`, the active half becomes its own `status: active` +record in the active files. Never relocate an active decision to `archive/`. First applied to the +#303 H9/H3 bundle (below): H9 (docs.decisions.md append-only) is genuinely superseded and archived as +`docs.append-only-guard`; H3 (the root-screenshot pre-commit guard) is independent and still active, so +it was split out into its own active record, `ci.root-screenshot-guard`, in +`docs/decisions/release-ci-governance.md` rather than being carried into archive with its superseded +sibling. + +## Notes on records considered but NOT given a standalone entry + +- **#390 (small-lane move for `api-docs`/`format`).** Investigated whether #390's reversal (described + in #406's prose, point 3: "#390's `small`-lane move ... reversed") ever existed as its own `##` + record. It did not — grep for `#390` across `docs/decisions.md` and `docs/decisions/*.md` finds it + only inside #406's own body text (and one other unrelated mention of #390's apt-ffmpeg estimate, + also inside #406). #390 was **prose-only**: no separate heading, so nothing to mark `superseded` or + move to archive. `ci.runner-placement` (#406) therefore carries `supersedes: none`; the reversal is + recorded only in its `**Signals:**` line (`#390 (prose-reversed, no standalone record)`) and in this + note. +- **#411 (the earlier `memory.peak`-headline measurement).** Investigated the "#411 obsolescence" the + brief references. #411 is mentioned twice in `docs/decisions.md`, both inside #412's own body + ("the `test` job's memory instrument (added in #411) now reports ..." and "the older #411 probe + ... is superseded"). Like #390, #411 never had its own `##` heading — its "decision" was + narrated retroactively inside #406 (the "Measurement is now continuous" paragraph, which describes + the instrument #411 actually shipped) and explicitly marked obsolete inside #412's own prose. Since + there is no standalone #411 record, there is nothing to move to `archive/`; `ci.peak-anon-measurement` + (#412) — the record that supersedes #411's approach — carries `supersedes: none` and documents the + prose reversal in its `**Signals:**` line. The governed surface (CI memory measurement) still exists + and is very much active, so this is a straightforward **supersession of an unnamed predecessor**, not + a retirement — there's no case to make for `retired` here since the surface it measures is live. + +## Still open (tracked for later migration passes, not this PR) + +- `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file` is + reversed by #520, not #521 — per the task-5 brief its supersession lands in PR2/Task 8, not here. +- The remaining ~90 headings in `docs/decisions.md` and the other three `docs/decisions/*.md` topic + files (`optimistic-concurrency.md`, `api-auth-security.md`, `spa-modularization.md`, and the rest of + `release-ci-governance.md`) are unmigrated (`status: legacy-unmigrated`, no metadata block). They are + valid under the schema as-is (the validator treats an unmigrated H2 as a migration target, not an + error) and migrate incrementally as each is next touched, per the arc's design. diff --git a/docs/decisions/release-ci-governance.md b/docs/decisions/release-ci-governance.md index c4397501c..f5300e9d0 100644 --- a/docs/decisions/release-ci-governance.md +++ b/docs/decisions/release-ci-governance.md @@ -7,8 +7,8 @@ auto-grant fix, migration rehearsal on a prod-DB copy, and release promotion. Ra relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation; operational detail cross-links to `docs/ci-cd.md` and CLAUDE.md → Task Completion Protocol. -Issue trail: #303 (H4/H5 api-docs gate, H6 Done-when, H9/H3 append-only + root-png, H10 -review-verdict), #311 (H11 formatting/rebase), #314 (merge-gate auto-grant), #315 (migration +Issue trail: #303 (H4/H5 api-docs gate, H6 Done-when, H9 append-only (archived), H3 root-png +(active), H10 review-verdict), #311 (H11 formatting/rebase), #314 (merge-gate auto-grant), #315 (migration rehearsal), #335 (release promotion). The whole hook program's throughline: make each process rule a derivation/hook, not prose to remember (#303 methodology review). @@ -16,7 +16,8 @@ rule a derivation/hook, not prose to remember (#303 methodology review). - [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5) - [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6) -- [2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)](#2026-07-12--decisionsmd-is-append-only-enforced-root-screenshot-guard-303-h9h3) +- **(archived 2026-07-21)** 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) — `docs.append-only-guard` is `status: superseded` by `docs.decision-lifecycle` (see `docs/decisions.md`); relocated to [`archive/release-ci-governance.md`](archive/release-ci-governance.md#2026-07-12--decisionsmd-is-append-only-enforced-root-screenshot-guard-303-h9h3) (heading kept identical to the pre-split original so the lifecycle validator's relocation check matches it by heading). The bundled companion root-screenshot guard (H3) was split out into its own active record, below. +- [2026-07-12 — Root-screenshot guard: pre-commit refuses root-level `*.png` (#303 H3)](#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) - [2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)](#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10) - [2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)](#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci) - [2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)](#2026-07-12--merge-consent-gate-auto-grants-when-satisfied-no-redundant-prompt-state-is-the-consent-314) @@ -62,26 +63,11 @@ issues adopt `## Done-when`, the merge hook simply *asks* rather than auto-allow Completion Protocol. (H6 lives with H1/H2/H8 in `.claude/settings.json`; H7 worktree-owner guard is its sibling Wave-2 hook.) -## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) - -**This log is append-only by construction, not just by convention.** A commit or PR that deletes or -modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook -(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same -script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook -gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff -(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the -push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line -near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is -`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-` -prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the -commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision -(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line -`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded -entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a -non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the -log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or -unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal, -consolidate at each milestone, size-floor backstop). +## 2026-07-12 — Root-screenshot guard: pre-commit refuses root-level *.png (#303 H3) +`key: ci.root-screenshot-guard` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none` +**Rule:** The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. +**Signals:** git hooks, screenshot guard, root png · paths: `.husky/pre-commit`, `.gitignore` · issues: #303 (H3) +**Mechanics:** `.husky/pre-commit` Companion guard **H3**: the Husky `pre-commit` hook refuses a staged **root-level `*.png`** (a review/debug screenshot dropped at the repo root) — belt-and-suspenders with the `.gitignore` rule, so diff --git a/docs/handoffs/chicorytv-issue-queue.md b/docs/handoffs/chicorytv-issue-queue.md index 77cab0503..a7e011cf8 100644 --- a/docs/handoffs/chicorytv-issue-queue.md +++ b/docs/handoffs/chicorytv-issue-queue.md @@ -249,7 +249,7 @@ HARD CONSTRAINTS: must supersede a live run, SAY SO explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and still works for re-triggering a **main** run.) Corrects the older "superseded runs drain on their own" note below: they do finish, but they hold a slot while doing it. -- **A lone `decisions.md append-only` red is a KNOWN INFRA FLAKE — do NOTHING** (operator-stated, +- **A lone `decisions lifecycle` red is a KNOWN INFRA FLAKE — do NOTHING** (operator-stated, 2026-07-19). When it is the **only** red: do not investigate it, and do **not** create a new run or commit to clear it — no rebase, no `--amend`, no no-op push. **The operator reruns that single job from the Gitea UI.** Report it as a known flake and carry on; only if *other* jobs are red too does diff --git a/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md new file mode 100644 index 000000000..00ec3a384 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md @@ -0,0 +1,1270 @@ +# 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: ` · `status: ` · `since: ` · `supersedes: ` · `superseded-by: ` + **Rule:** + **Signals:** · paths: · issues: #NNN + **Mechanics:** + ``` +- `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 + + +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 = "" + + +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>@`. 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 ``. 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: `' 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." + 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 + \". 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 ✔. diff --git a/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md b/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md new file mode 100644 index 000000000..a86665ee0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md @@ -0,0 +1,217 @@ +# Decision-lifecycle + retrieval-efficient startup — design (ersatztv #520 + #521) + +Date: 2026-07-21 +Issues: [#521](http://192.168.1.95:3000/timothy/ersatztv/issues/521) (decision knowledge lifecycle), +[#520](http://192.168.1.95:3000/timothy/ersatztv/issues/520) (retire #237 from startup; parallel orientation + selection). +Both originate from a bounded knowledge-retrieval audit in `timothy/adversarial-reviewer`. + +## Goal + +Two coupled problems from the same audit: + +1. **#521** — `docs/decisions.md` (~2,468 lines; ~3,469 incl. topic files) is optimized for *append + safety*, not *current-state retrieval*. Status lives in prose, supersession is inferred at + release time, and fresh agents are told to read the corpus broadly before the task is known. +2. **#520** — Startup still tells agents (and the selector fallback) to read the closed arc tracker + #237 and its stale forward-looking comments, and preloads the whole doc corpus before the task is + known. `scripts/select-queue.sh` already does live mechanical selection correctly. + +The fix is one arc: **give decisions a stable identity + explicit lifecycle + a compact generated +active view (#521), then point startup at that compact view and run orientation ‖ selection in +parallel (#520).** Keep Git-backed Markdown authoritative; MemPalace stays candidate-discovery only. + +## Non-goals + +- Making MemPalace the source of truth; storing live queue state in decision docs. +- Reopening #237 or creating a new manual session-log issue / Markdown ledger. +- Implementing the MemPalace/Gitea exporter (`server-management#642`). +- Changing `select-queue.sh`'s dependency/tier/priority algorithm (only its comments/output text), + unless a test exposes a bug. + +## Delegation model (agreed) + +- **Judgment (orchestrator / escalated):** schema, validator, catalog generator, kickoff + docs + rewrite, and reconciling the prose-only reversals (#390→#406; #411 measurement obsolescence). +- **Mechanical (balanced/cheap subagents, fanned out by topic batch):** reformatting existing + entries into the keyed schema. Each subagent gets exact schema rules + the batch's entries + a + worktree/branch of its own; results merge back. No two committing agents share a worktree. + +## Guard rework (the crux) + +The current append-only guard blocks **any** modified/deleted line in `decisions.md` unless the +commit carries `[decisions-edit]` (`.claude/hooks/decisions-guard.sh`, wired to Husky `commit-msg` +and the CI `decisions-guard` job). That line-level mechanic is **incompatible** with a lifecycle +model where migration reformats every entry, supersession *edits* a predecessor's `superseded-by:` +line, and retiring *moves* a record to archive. + +**Decision:** replace the line-level mechanic with a **lifecycle validator** that enforces the same +*spirit* — rationale is never silently rewritten or deleted; every history touch is deliberate and +reviewable. The `[decisions-edit]` token is **kept narrow, not retired** (revised after Fable review): +routine lifecycle *metadata* writes (add record, set `superseded-by`, relocate to archive, regen +catalog) are token-free and proven well-formed by the validator; a change to a record's *rationale +prose* still requires `[decisions-edit]`, enforced by the validator's **body-diff**. Retiring the +token entirely would let an agent silently rewrite rationale (or archive a falsified body) and pass — +the exact case the old guard existed for. + +Spirit preserved by these validator invariants (see Validator below): ≤1 active record per key; no +record block may vanish (if it leaves the active set it must reappear under `docs/decisions/archive/`); +**a surviving record's rationale prose can't change, and an archived copy must body-match its pre-move +active version, without `[decisions-edit]` in the commit range**; supersession links are reciprocal +and resolvable; status only moves active → superseded/retired. The no-vanish/body-diff checks run in +CI over `merge-base(base,head)…head` across `decisions.md` **and** every topic file. + +**Delivery: two PRs, one arc** (revised after Fable review). PR1 = machinery (parser, validator, +catalog generator, guard swap, header rewrite, exemplar + append-only→lifecycle supersession); PR2 = +full corpus migration + #520 startup rewrite + kickoff guard + retrieval-eval. Matches #521's +"bounded topic batches, avoid a single conflict-heavy rewrite" and keeps each diff's conflict surface +small on the treadmill-prone `decisions.md`. + +## Information model (#521) + +Each decision record carries a compact, **visible**, deterministically-parseable metadata block +immediately under its `##` heading (front-loaded so MemPalace indexes key/status/rule first): + +``` +## 2026-07-17 — No persistent compiler servers in CI … (#406) +`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none` +**Rule:** Every CI `services:` container gets an explicit CPU/mem cap; no shared persistent compiler daemon. +**Signals:** ci, runners, services, memory-cap · paths: .gitea/workflows/docker-build.yml · issues: #390 #406 +**Mechanics:** docs/ci-cd.md → CI lanes + +``` + +Fields (issue-required): + +- `key` — stable subject identity, dotted lowercase (`^[a-z0-9]+(\.[a-z0-9-]+)+$`), e.g. + `ci.runner-placement`. A superseding record reuses the predecessor's key. +- `status` — `active` | `superseded` | `retired` | `legacy-unmigrated` (the last is a transitional + marker; see Migration). "Relocated"/"consolidated" are maintenance actions, not statuses. +- `since` — effective date (YYYY-MM-DD) or version. +- `supersedes` / `superseded-by` — predecessor/successor key+date, or `none`. +- `**Rule:**` — one-line current rule, before the long rationale. +- `**Signals:**` — concepts, `paths:`, `issues:` for task-driven retrieval. +- `**Mechanics:**` — link(s) to the current convention/source/test anchors. +- Rationale prose (incl. rejected alternatives) follows, unchanged from today where migrated. + +Rejected alternatives stay *inside* their accepted record as rationale; they are never active +records. + +## Document architecture (#521) + +- `docs/decisions.md` — stays the chronological record store for in-file (non-topic) decisions, now + in schema form. Header rewritten: append-only-log framing → lifecycle framing. +- `docs/decisions/*.md` — existing topic files, entries migrated to schema form. +- **`docs/decisions/README.md` — NEW generated active catalog / task router.** A compact table + (key · one-line rule · status · record link · signals) built by + `scripts/build-decisions-catalog.py` from all `active` records across `decisions.md` + topic files. + Regenerated like `endpoint-index.md`; drift fails CI. This is the compact "active view" startup + points at. +- **`docs/decisions/archive/` — NEW.** `superseded`/`retired` records relocated here, out of the + default startup path, with forward/back links preserved. Not scanned by the catalog generator. + +## Validator (#521) + +`scripts/decisions-validate.py`, run locally (a Husky hook replacing the append-only commit-msg +check) and in the CI `decisions-guard` job (renamed → lifecycle guard). Checks: + +1. Required metadata present + well-formed on every structured record. +2. Valid `status` enum; valid `key` format. +3. **≤1 `active` record per key.** +4. Reciprocal, resolvable `supersedes`/`superseded-by` links (successor exists; predecessor points + back). +5. **No-vanish (diff-aware):** any record block removed from the active files (`decisions.md` + + topic files) between base and HEAD must be present under `docs/decisions/archive/`. Fail-open on + missing refs, like the current guard. +6. Active-catalog completeness + freshness (regenerate to a temp file, diff — like `check:api`). +7. Archive/active placement consistency (no `active` record in archive; no `superseded`/`retired` + in the active set once migration of its key is complete). +8. **Aggregate active-corpus budget** — sum of lines across `decisions.md` + topic files + catalog + (excluding archive). Warn over a threshold (start at the current 1800 applied to the aggregate; + tune during migration). Report the `legacy-unmigrated` remainder count; it must be visible and + trend to zero. +9. Best-effort broken-link check on `Mechanics:`/archive links where practical. + +Fail-open on tooling trouble, matching the current guard's philosophy. + +## Startup / kickoff rewrite (#520) + +- **`docs/handoffs/chicorytv-issue-queue.md`** — retire #237 from normal startup. Two concurrent + tracks when no issue is named: **orientation** (`AGENTS.md`, `CLAUDE.md`, compact `docs/README.md` + map + the active catalog) ‖ **selection** (`scripts/select-queue.sh 5`). After both: resolve only + `CLAIM?`/`UMBRELLA?` flags + the winner, recheck live state, claim, then build a focused + task-specific knowledge packet. Named-issue path skips selection → focused retrieval directly. + Archive the contradictory *pre-script* selector lore (the "re-derive the whole contested tier by + hand" bullets) into a clearly-labeled historical section. #237 appears only as labeled archival + history. +- **`docs/README.md`** — mandatory 1–10 reading order → compact **task-signal → minimal sections** + authority/task map, plus a pointer to `docs/decisions/README.md` as the decision active view. +- **`CLAUDE.md`** — docs-first guidance requires the map + relevant sections, not the whole corpus. + Task-completion protocol gains the structured `## Closing record` template. +- **`scripts/select-queue.sh`** — comments/output: active tiers lead with open + milestones/review/priorities; drop "read #237" phrasing. +- **Structured closing record** — documented template (Outcome / Root cause / Decisions-conventions + changed / Reusable knowledge / Verification / Deferred-follow-ups / Docs updated) as the future + per-issue history record. No second manual ledger. + +### Regression check (#520 "cannot regress" box) + +`scripts/check-kickoff-guard.sh` (shares the lifecycle CI job): fails if active kickoff/README/ +CLAUDE text reintroduces "#237 is the queue / source of truth / read #237 for current state" +patterns. Allow-list the explicitly-archival mentions. + +## Migration plan (#521 — full, via subagents) + +1. Land schema rules + validator + catalog generator + archive skeleton first (validator tolerant of + `legacy-unmigrated`). +2. Inventory legacy entries by stable subject (not just chronology); assign keys. +3. Reconcile known prose-only reversals/retirements first (#390→#406 supersession; #411 + obsolescence) — orchestrator/escalated, not a cheap subagent. +4. Fan out mechanical reformatting in bounded topic batches (each subagent: exact schema + its + entries + its own branch). Merge back. +5. Move superseded/retired records to `docs/decisions/archive/` with links. +6. Regenerate the catalog; flip aggregate budget check on; drive `legacy-unmigrated` to zero. +7. Preserve an auditable mapping from every legacy heading to its active record or archive location + (a migration map committed alongside). + +Safety valve: if mechanical migration balloons, land complete machinery + a partial migration with a +visible remainder count + a tracked follow-up issue (issue done-when explicitly allows a bounded +remainder with no ambiguous active rules). + +## Retrieval evaluation (#521) + +A bounded question bank (committed, e.g. `docs/decisions/retrieval-eval.md` or a test fixture) +covering: paraphrased task→decision discovery; exact code/path lookup; active-vs-superseded; retired +features; rationale/rejected-alternatives; and at least one "convention already implemented" +question that must prevent reimplementation. Success = correct **active-record selection + citation**, +not merely a semantically-related passage. Executed as the cold-agent step below (deterministic +grading where the answer is a specific key/citation). + +## Verification + +- **Deterministic:** validator + regression check + catalog freshness all green locally before push. +- **Behavioral (cold-agent sim, #520 done-when):** a fresh subagent handed only the new + kickoff/README produces the startup flow — orientation ‖ selector, resolve flags, recheck+claim + winner, focused retrieval — and **never reads #237**. Second cold agent runs a sample of the + retrieval question bank against the new active view. +- **Independent adversarial review** of the whole diff (mandatory: >150 lines, touches CI) before + push; cross-model if available, else a cold review-only agent. +- Docs updated in the same PR (README index, decisions header, ci-cd release ritual, CLAUDE.md). + +## CI / release-ritual changes + +- Rename/extend the `decisions-guard` job → run `decisions-validate.py` + `check-kickoff-guard.sh` + + catalog freshness. +- Release ritual (`docs/ci-cd.md` → Versioning & releases): change "discover superseded entries" → + "validate lifecycle metadata + reciprocal links; archive already-classified history; refresh the + active catalog; enforce the aggregate budget; report unresolved legacy records." +- Remove `[decisions-edit]` from the Husky `commit-msg` hook and the CI job; update docs/memory that + reference the token. + +## Risks + +- **Conflict surface:** `decisions.md` conflicts on nearly every CI cycle here; this arc rewrites it + wholesale. Mitigation: single PR, rebase-then-`merge_when_checks_succeed`, land fast. +- **Regenerated-artifact merges:** on rebase conflicts in the generated catalog, regenerate (never + hand-merge), same discipline as `v1.json`. +- **Memory/lore references** to `[decisions-edit]` and "#237 is the queue" are spread across + MEMORY.md and the handoff lore; sweep by subject. diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/build_decisions_catalog.py b/scripts/build_decisions_catalog.py new file mode 100644 index 000000000..a8f50be58 --- /dev/null +++ b/scripts/build_decisions_catalog.py @@ -0,0 +1,87 @@ +#!/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 = "" + + +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 slugger: lowercase, keep alphanumerics + underscore, spaces/hyphens → '-' each (one + # hyphen PER such char — Gitea does NOT collapse runs of '-'), drop other punctuation. No + # collapsing, no trimming. Keeping '_' matters — headings like "…(`iptv.base_url`)…" anchor to + # …iptvbase_url…. A " — " (space em-dash space) heading therefore anchors with "--", e.g. + # "2026-07-19 — Foo" → "2026-07-19--foo". VERIFIED against this repo's own working Index + # anchors — do not "fix" this back to a collapsed form without re-checking against Gitea. + 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.rstrip("\n") + "\n", encoding="utf-8") + print(f"build_decisions_catalog: wrote {OUTPUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/decisions_lib.py b/scripts/decisions_lib.py new file mode 100644 index 000000000..e1ba4b1c6 --- /dev/null +++ b/scripts/decisions_lib.py @@ -0,0 +1,115 @@ +#!/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 + + +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) + + +_NON_DECISION_FILES = {"README.md", "migration-map.md"} + + +def active_files() -> list[Path]: + files = [DECISIONS_MD] + files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name not in _NON_DECISION_FILES) + 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 diff --git a/scripts/decisions_validate.py b/scripts/decisions_validate.py new file mode 100644 index 000000000..1c3da25f9 --- /dev/null +++ b/scripts/decisions_validate.py @@ -0,0 +1,328 @@ +#!/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]" # noqa: S105 (a commit-message marker, not a credential) + + +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, + archive_records=None, + demoted=(), +) -> list[str]: + errs: list[str] = [] + archive_records = archive_records or [] + 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 + if r.status in ("superseded", "retired"): + errs.append( + f"{r.heading}: status {r.status} but still in the active set — relocate to docs/decisions/archive/" + ) + + for r in archive_records: + if r.status == "active": + errs.append(f"{r.heading}: active record must not live under docs/decisions/archive/") + + for key, n in active_by_key.items(): + if n > 1: + errs.append(f"key {key!r}: more than one active record ({n})") + + # by_key map across BOTH wings, for reciprocity checks (existence-only checks still use + # known_keys, which additionally includes keys the caller only knows about via archive_keys). + by_key: dict[str, dl.Record] = {} + for r in decision_recs + list(archive_records): + if r.key: + by_key[r.key] = r + + # reciprocal supersession (successor may live in active OR archive) + for r in decision_recs: + if r.status == "active" and r.superseded_by not in (None, "", "none"): + errs.append(f"{r.heading}: active record cannot already be superseded (superseded-by set)") + + sk = _key_of(r.superseded_by) + if sk: + if sk not in known_keys: + errs.append(f"{r.heading}: superseded-by points to unknown key {sk!r}") + else: + b = by_key.get(sk) + if b is not None and r.key and _key_of(b.supersedes) != r.key: + errs.append(f"{r.heading}: superseded-by {sk} but {sk} does not point back (supersedes)") + pk = _key_of(r.supersedes) + if pk: + if pk not in known_keys: + errs.append(f"{r.heading}: supersedes points to unknown key {pk!r}") + else: + a = by_key.get(pk) + if a is not None and r.key and _key_of(a.superseded_by) != r.key: + errs.append(f"{r.heading}: supersedes {pk} but {pk} does not point back (superseded-by)") + + 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") + for h in demoted: + errs.append(f"{h}: migrated record demoted to legacy-unmigrated (metadata block removed)") + + if not catalog_ok: + errs.append("docs/decisions/README.md active catalog is stale — run build_decisions_catalog.py") + if not budget_ok: + # non-blocking: reported as a warning by the caller (main()), never added here. + pass + return errs + + +# ---- git helpers (all fail-open: return neutral values on any error) ---- + + +def _run(args: list[str]) -> str | None: + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError): + return None + 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/migration-map.""" + 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 Path(p).name in dl._NON_DECISION_FILES: + 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 contiguous top metadata block stripped, whitespace-normalized. + + Only the block from the first non-blank line (when it is the `key:` meta line) up to the first + following blank line is metadata. A **Rule:**/**Signals:**/**Mechanics:**/`key:` line appearing + later in rationale prose is prose, not metadata (mirrors decisions_lib's contiguous-block rule). + """ + lines = rec.body.splitlines() + start = 0 + while start < len(lines) and not lines[start].strip(): + start += 1 + if start < len(lines) and lines[start].strip().startswith("`key:"): + end = start + 1 + while end < len(lines) and lines[end].strip(): + end += 1 + rest = lines[:start] + lines[end:] + else: + rest = lines + return "\n".join(s for s in (ln.strip() for ln in rest) if s) + + +def _diff_findings(base: str, head: str) -> tuple[list[str], list[str], list[str]]: + """(removed, rewritten, demoted) between merge-base(base,head) and head. Fail-open → ([], [], []).""" + mb = _merge_base(base, head) + if not mb: + print( + f"::warning::decisions-validate: could not resolve merge-base({base},{head}); no-vanish/body-diff skipped", + file=sys.stderr, + ) + 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) + base_archive = _archive_records_at(mb) + + gone = set(base_active) - set(head_active) + removed = sorted(h for h in gone if h not in head_archive) + + # an archive record present at base must not vanish entirely (neither wing has it at head) + archive_gone = set(base_archive) - set(head_archive) + removed += sorted(h for h in archive_gone if h not in head_active) + + 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) + # archive records that survive in the archive wing: rationale must not be rewritten either + for h in set(base_archive) & set(head_archive): + if _rationale(base_archive[h]) != _rationale(head_archive[h]): + rewritten.append(h) + + # migrated (had a key) at base, demoted to legacy-unmigrated (no key, or key changed) at head, + # same heading, still present in the active set. + demoted: list[str] = [] + for h in set(base_active) & set(head_active): + base_key = base_active[h].key + head_key = head_active[h].key + if base_key and base_key != head_key: + demoted.append(h) + + return sorted(set(removed)), sorted(set(rewritten)), sorted(demoted) + + +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_total() -> int: + 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 + + +def _budget_ok(limit: int) -> bool: + return _budget_total() <= limit + + +def _catalog_ok() -> bool: + try: + import scripts.build_decisions_catalog as bc # pyright: ignore[reportMissingImports] + 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() + archive_records = [] + if dl.ARCHIVE_DIR.exists(): + for f in dl.ARCHIVE_DIR.glob("*.md"): + archive_records += dl.parse_file(f) + removed, rewritten, demoted = _diff_findings(args.base, args.head) if args.base and args.head else ([], [], []) + budget_ok = _budget_ok(args.budget) + errs = validate( + records, + archive_keys=_archive_keys(), + catalog_ok=_catalog_ok(), + budget_ok=budget_ok, + removed=removed, + rewritten=rewritten, + archive_records=archive_records, + demoted=demoted, + ) + + if not budget_ok: + print( + f"::warning::decisions-validate: aggregate active-corpus is {_budget_total()} lines " + f"(budget {args.budget}) — schedule a consolidation", + file=sys.stderr, + ) + + 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 (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()) diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/tests/fixtures/sample_decisions.md b/scripts/tests/fixtures/sample_decisions.md new file mode 100644 index 000000000..ab3bbafb1 --- /dev/null +++ b/scripts/tests/fixtures/sample_decisions.md @@ -0,0 +1,15 @@ +# 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. diff --git a/scripts/tests/test_build_catalog.py b/scripts/tests/test_build_catalog.py new file mode 100644 index 000000000..820463390 --- /dev/null +++ b/scripts/tests/test_build_catalog.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import scripts.build_decisions_catalog as bc +import scripts.decisions_lib as dl + + +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 + + +def test_anchor_matches_gitea_double_hyphen_slug(): + # Ground truth: Gitea does NOT collapse hyphen runs. " — " (space, em dash, space) becomes + # "--" in the anchor (one hyphen per space/dash char), never collapsed to a single "-". + a = bc._anchor( + "2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)" + ) + assert a == ("2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412") + + b = bc._anchor("2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`)…") + assert "iptvbase_url" in b diff --git a/scripts/tests/test_decisions_lib.py b/scripts/tests/test_decisions_lib.py new file mode 100644 index 000000000..35ad6db08 --- /dev/null +++ b/scripts/tests/test_decisions_lib.py @@ -0,0 +1,34 @@ +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 r.signals is not None + assert "issues: #390 #406" in r.signals + assert r.mechanics is not None + 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_parses_as_heading(): + recs = dl.parse_file(FIX) + assert any(r.heading == "Index" for r in recs) diff --git a/scripts/tests/test_decisions_validate.py b/scripts/tests/test_decisions_validate.py new file mode 100644 index 000000000..352e21c57 --- /dev/null +++ b/scripts/tests/test_decisions_validate.py @@ -0,0 +1,329 @@ +import subprocess +from pathlib import Path +from typing import Any + +import scripts.decisions_lib as dl +import scripts.decisions_validate as dv + + +def _rec(**kw: Any) -> dl.Record: + base: dict[str, Any] = dict( + heading="H", + source=Path("x"), + lineno=1, + status="active", + since="2026-01-01", + supersedes="none", + superseded_by="none", + ) + base.update(kw) + return dl.Record(**base) + + +def _v(recs: list[dl.Record], **kw: Any) -> list[str]: + args: dict[str, Any] = dict( + archive_keys=set(), + catalog_ok=True, + budget_ok=True, + removed=[], + rewritten=[], + archive_records=[], + demoted=[], + ) + 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_reciprocal_superseded_by_with_no_back_link_fails(): + # B exists (has a record) but its `supersedes` does not point back to A → dangling back-link. + a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01") + b = _rec(heading="B", key="a.c", status="active", supersedes="none") + assert any("does not point back" in e and "a.c" in e for e in _v([a], archive_records=[b], archive_keys={"a.c"})) + + +def test_reciprocal_supersession_correct_pair_passes(): + # active supersedes archived; archived is superseded-by active → reciprocal, no errors. + active = _rec(heading="Active", key="a.b", status="active", supersedes="a.c@2026-01-01") + archived = _rec(heading="Archived", key="a.c", status="superseded", superseded_by="a.b@2026-07-01") + errs = _v([active], archive_records=[archived], archive_keys={"a.c"}) + assert errs == [] + + +def test_active_record_with_superseded_by_fails(): + recs = [_rec(key="a.b", status="active", superseded_by="a.c@2026-07-01")] + assert any("active record cannot already be superseded" 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")]) == [] + + +def test_superseded_record_in_active_fails(): + recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-01-01")] + assert any("relocate to docs/decisions/archive/" in e for e in _v(recs, archive_keys={"a.c"})) + + +def test_active_record_in_archive_fails(): + assert any( + "must not live under docs/decisions/archive/" in e + for e in _v([], archive_records=[_rec(key="a.b", status="active")]) + ) + + +def test_missing_since_fails(): + rec = dl.Record( + heading="H", + source=Path("x"), + lineno=1, + key="a.b", + status="active", + since=None, + supersedes="none", + superseded_by="none", + ) + assert any("missing required metadata since" in e for e in _v([rec])) + + +def test_demoted_heading_fails(): + assert any( + "demoted to legacy-unmigrated" in e for e in _v([_rec(key="a.b")], demoted=["2026-01-01 — Some decision (#1)"]) + ) + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + +def _write_decisions(path: Path, *, status: str, since: str, rationale: str) -> None: + path.write_text( + "## 2026-01-01 — Some decision (#1)\n" + f"`key: a.b` · `status: {status}` · `since: {since}` · `supersedes: none` · `superseded-by: none`\n" + "**Rule:** one-line current rule.\n" + "**Signals:** concept · paths: a/b.py · issues: #1\n" + "**Mechanics:** docs/foo.md\n" + "\n" + f"{rationale}\n", + encoding="utf-8", + ) + + +def test_diff_engine_detects_rationale_rewrite_without_token(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + # (a) rewrite rationale prose only, no token + _write_decisions( + repo / "docs" / "decisions.md", + status="active", + since="2026-01-01", + rationale="**Rule:** this looks like metadata but is prose appended later.", + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "rewrite rationale") + + removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert removed == [] + assert "2026-01-01 — Some decision (#1)" in rewritten + + +def test_diff_engine_allows_rewrite_with_token(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Reworded prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "rewrite rationale [decisions-edit]") + + removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert removed == [] + assert rewritten == [] + + +def test_diff_engine_detects_appended_smuggled_rationale(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + # append-shape exploit: original prose line is PRESERVED, a smuggled **Rule:** paragraph is + # appended after a blank line, no [decisions-edit] token. The old buggy _rationale() strips + # ANY `**Rule:**`/`**Signals:**`/`**Mechanics:**`/`` `key:`` line wherever it appears, so it + # silently strips the appended line too → base==head → bypass succeeds. The bounded strip only + # removes the contiguous top metadata block, so the appended paragraph survives as prose → + # base!=head → flagged. + _write_decisions( + repo / "docs" / "decisions.md", + status="active", + since="2026-01-01", + rationale="Original prose.\n\n**Rule:** smuggled rewrite that changes the actual meaning.", + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "append smuggled rewrite") + + removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert removed == [] + assert "2026-01-01 — Some decision (#1)" in rewritten + + +def test_diff_engine_metadata_only_edit_is_free(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + _write_decisions(repo / "docs" / "decisions.md", status="active", since="2026-01-01", rationale="Original prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + # metadata-only edit: status/since change, prose unchanged, no token + _write_decisions( + repo / "docs" / "decisions.md", status="superseded", since="2026-02-01", rationale="Original prose." + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "metadata edit") + + removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert removed == [] + assert rewritten == [] + + +def _write_archive(path: Path, *, rationale: str) -> None: + path.write_text( + "## 2026-01-01 — Some archived decision (#2)\n" + "`key: a.z` · `status: superseded` · `since: 2026-01-01` · `supersedes: none` · " + "`superseded-by: a.b@2026-07-01`\n" + "**Rule:** one-line historical rule.\n" + "**Signals:** concept · paths: a/z.py · issues: #2\n" + "**Mechanics:** docs/foo.md\n" + "\n" + f"{rationale}\n", + encoding="utf-8", + ) + + +def test_diff_engine_detects_archive_rationale_rewrite_without_token(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs" / "decisions" / "archive").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + archive_path = repo / "docs" / "decisions" / "archive" / "x.md" + _write_archive(archive_path, rationale="Original archived prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + _write_archive(archive_path, rationale="Rewritten archived prose, no token.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "rewrite archived rationale") + + _removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert "2026-01-01 — Some archived decision (#2)" in rewritten + + +def test_diff_engine_detects_archive_record_removed(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs" / "decisions" / "archive").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + archive_path = repo / "docs" / "decisions" / "archive" / "x.md" + _write_archive(archive_path, rationale="Original archived prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + archive_path.unlink() + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "delete archived record") + + removed, _rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD") + assert "2026-01-01 — Some archived decision (#2)" in removed + + +def test_diff_engine_demoted_migrated_to_legacy_unmigrated(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + _git(tmp_path, "init", str(repo)) + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + + decisions_path = repo / "docs" / "decisions.md" + _write_decisions(decisions_path, status="active", since="2026-01-01", rationale="Original prose.") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base") + + monkeypatch.chdir(repo) + + # same heading, metadata block stripped: prose survives, migration silently reverted. + decisions_path.write_text( + "## 2026-01-01 — Some decision (#1)\n\nOriginal prose.\n", + encoding="utf-8", + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "demote to legacy-unmigrated") + + _removed, _rewritten, demoted = dv._diff_findings("HEAD~1", "HEAD") + assert "2026-01-01 — Some decision (#1)" in demoted