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

Merged
timothy merged 17 commits from feat/520-521-decision-lifecycle into main 2026-07-21 11:08:52 +02:00
25 changed files with 2779 additions and 150 deletions
+11 -53
View File
@@ -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 <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> 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
+15 -18
View File
@@ -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
-5
View File
@@ -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
+6
View File
@@ -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)
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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/`:
+48 -27
View File
@@ -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 <n>`, 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/<base> --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
+102 -19
View File
@@ -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 <new entry title>.**` 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
> \<rationale prose …\>
(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: <new key>` 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).
+14
View File
@@ -0,0 +1,14 @@
<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->
# 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) |
+22
View File
@@ -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.
@@ -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`.
+58
View File
@@ -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.
+9 -23
View File
@@ -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
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
<existing rationale prose, incl. rejected alternatives — unchanged>
```
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.
View File
+87
View File
@@ -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 = "<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->"
def _rel(rec) -> str:
"""Link from docs/decisions/README.md to the record's heading."""
src = Path(rec.source).name
anchor = _anchor(rec.heading)
if src == "decisions.md":
return f"../decisions.md#{anchor}"
return f"{src}#{anchor}"
def _anchor(heading: str) -> str:
# Gitea 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())
+115
View File
@@ -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
<rationale prose …>
An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"
STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")
@dataclass
class Record:
heading: str
source: Path
lineno: int
key: str | None = None
status: str = "legacy-unmigrated"
since: str | None = None
supersedes: str | None = None
superseded_by: str | None = None
rule: str | None = None
signals: str | None = None
mechanics: str | None = None
body: str = ""
def _parse_meta_line(line: str) -> dict[str, str]:
return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}
def parse_text(text: str, source: Path) -> list[Record]:
lines = text.splitlines()
records: list[Record] = []
i = 0
while i < len(lines):
m = _HEADING_RE.match(lines[i])
if not m:
i += 1
continue
rec = Record(heading=m.group(1), source=source, lineno=i + 1)
j = i + 1
body_lines: list[str] = []
while j < len(lines) and not _HEADING_RE.match(lines[j]):
body_lines.append(lines[j])
j += 1
for k, bl in enumerate(body_lines):
if not bl.strip():
continue
meta = _parse_meta_line(bl)
if "key" in meta and "status" in meta:
rec.key = meta.get("key") or None
rec.status = meta.get("status") or "legacy-unmigrated"
rec.since = meta.get("since") or None
rec.supersedes = meta.get("supersedes") or None
rec.superseded_by = meta.get("superseded-by") or None
# The metadata block is contiguous: scan only until the first blank line, so a
# bolded **Rule:** appearing later inside rationale prose can't overwrite the real one.
for bl2 in body_lines[k + 1 :]:
if not bl2.strip():
break
if bl2.startswith("**Rule:**"):
rec.rule = bl2[len("**Rule:**") :].strip()
elif bl2.startswith("**Signals:**"):
rec.signals = bl2[len("**Signals:**") :].strip()
elif bl2.startswith("**Mechanics:**"):
rec.mechanics = bl2[len("**Mechanics:**") :].strip()
break
rec.body = "\n".join(body_lines).strip()
records.append(rec)
i = j
return records
def parse_file(path: Path) -> list[Record]:
return parse_text(path.read_text(encoding="utf-8"), path)
_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
+328
View File
@@ -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())
View File
+15
View File
@@ -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.
+51
View File
@@ -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
+34
View File
@@ -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)
+329
View File
@@ -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