fix(609): arm the decisions body-diff with a git trailer, not a bare substring

The rationale-edit exemption was a substring test over the whole commit range, so any
message containing the literal marker armed it -- including prose explaining why no
marker was needed, which is how it fired live in PR #605: a green --base/--head run
that was vacuous on the body-diff dimension, in the one PR that hand-resolved a merge
conflict inside the corpus the guard exists to police.

Now read as an affirmative `Decisions-Edit:` git trailer. Git parses trailers only in
the final paragraph, so a quoted example message cannot arm it -- which matters because
this commit and its decision record both quote one. A non-affirmative value (`no`) does
not arm it either; the retired substring arms nothing and gets a ::warning:: nudge.

Tests: negative controls (prose mention, quoted mid-body trailer, `no` value, retired
substring) plus positive controls (trailer, uppercase, alongside Co-Authored-By). All
four negative controls verified red against the old matcher.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 18:21:12 +02:00
co-authored by Claude Opus 5
parent 54ed75624a
commit c597c49f02
7 changed files with 161 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
# the lifecycle validator. A `Decisions-Edit: yes` trailer 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
+2 -1
View File
@@ -160,7 +160,8 @@ jobs:
# 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
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
# trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), 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.
+4 -4
View File
@@ -60,8 +60,8 @@ step is now continuous. The release boundary is instead where you:
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.
A genuine rationale-prose rewrite still needs a `Decisions-Edit: yes` git trailer on a commit in the
range (see the `decisions.md` header) — routine lifecycle metadata writes above do not.
**Cutting a release:** keep build and promotion as two explicit phases (#335):
@@ -578,7 +578,7 @@ blocking `api-docs` job, and `docs/decisions.md` by the blocking `decisions-guar
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,
archive copy, no rationale-prose rewrite without a `Decisions-Edit: yes` trailer 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).
@@ -1062,7 +1062,7 @@ entirely in `web/`), the wiring is:
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`). The decision-lifecycle check lives in `pre-commit`
(above), not here — `[decisions-edit]` is read from the commit message, but only by the CI
(above), not here — the `Decisions-Edit:` trailer 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.
+38 -5
View File
@@ -55,7 +55,9 @@ 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
**`Decisions-Edit: yes`** (a git **trailer** — the message's final paragraph, alongside
`Co-Authored-By:`; the pre-#609 `[decisions-edit]` substring is retired and no longer exempts
anything) is 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
@@ -3080,7 +3082,8 @@ an `active` record MUST NOT live under `archive/`; every `supersedes`/`supersede
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
carries the rationale-edit marker (`Decisions-Edit: yes`, a git trailer since #609 — see
`ci.decisions-edit-trailer`), 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.
@@ -3092,9 +3095,9 @@ validator that checks reciprocal keys, archive placement, and rationale-prose st
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.
The rationale-edit marker 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 marker-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
@@ -3918,3 +3921,33 @@ owner's sweep **destroyed** the row the other library still served. This is the
rather than *destroys* that shared row. The unrecoverable data loss is gone; the shared-row limitation is a
whole-app property, not a music-video one, and is tracked separately as **#606**. #496's Done-when was
amended to this parity wording rather than ticked as literally written.
## 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609)
`key: ci.decisions-edit-trailer` · `status: active` · `since: 2026-07-25` · `supersedes: none` · `superseded-by: none`
**Rule:** The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive) on some commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON.
**Signals:** decisions-edit, Decisions-Edit trailer, rationale-prose edit, body-diff guard, decisions_validate, vacuous gate, green no-op · paths: `scripts/decisions_validate.py`, `scripts/tests/test_decisions_validate.py` · issues: #609, #603, #521
**Mechanics:** `git log --format='%(trailers:key=Decisions-Edit,valueonly)' <mb>..<head>`; `dv._edit_trailer_armed`
**The guard could disable itself by being described.** The original check was
`EDIT_TOKEN.lower() in git log --format=%B mb..head`, so *any* commit message containing the literal
string anywhere set `token = True` and suppressed all three rewrite comparisons (active survivors,
active→archive laundering, archive survivors). It fired live in PR #605: a commit message explaining
*why no token was needed* contained the bracketed token, and armed it. The result was a **green
`--base/--head` run that was vacuous on the body-diff dimension**, in the one PR that hand-resolved a
merge conflict inside the corpus the guard exists to police. No content was damaged, but the gate
reported success while checking nothing — the "a gate can merge green yet be a no-op" class.
**Why a trailer and not an own-line match.** Requiring the token alone on its own line fixes the
observed instance, but not the class: this repo's docs commits routinely *quote example commit
messages*, and the commit introducing this very record does so. An own-line matcher would arm on the
example. Git recognises a trailer only in the message's final paragraph, so a `Decisions-Edit: yes`
line quoted mid-body followed by more prose parses as nothing at all (verified against git 2.55) —
which makes documenting the mechanism structurally incapable of disabling it. Requiring an
*affirmative value* closes the same trap one level up: `Decisions-Edit: no`, the natural way to record
a deliberate non-edit, must not read as consent.
**The marker's scope is unchanged** — still narrow, still only for a rationale-prose edit to a
surviving or archived record (`docs.decision-lifecycle`); only its form changed. It coexists with the
`Co-Authored-By:` trailer the `commit-msg` hook already mandates, so armed commits carry a
multi-trailer block. The retired form is left detectable on purpose: a contributor with the old habit
gets a warning naming the form change, not a bare "prose changed" failure.
+1
View File
@@ -36,6 +36,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--batch-your-pushes-there-is-no-agent-side-cancel-route-on-gitea-1254-542) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](../decisions.md#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](workflow-process.md#2026-07-21--cancelled-is-not-failure-a-cancelled-run-is-no-verdict-542) |
| `ci.decisions-edit-trailer` | The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive) on some commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON. | 2026-07-25 | [link](../decisions.md#2026-07-25--the-rationale-edit-marker-is-a-git-trailer-not-a-substring-anywhere-in-the-commit-range-609) |
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lone-decisions-lifecycle-red-is-a-known-infra-flake-do-nothing-542) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
+38 -7
View File
@@ -3,7 +3,7 @@
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
reviewable. A `Decisions-Edit: yes` commit trailer is kept ONLY for rationale-prose edits; routine
lifecycle metadata writes are token-free. Fail-open on tooling trouble (missing refs, git errors,
parse issues), matching the old guard.
"""
@@ -25,7 +25,15 @@ SKIP_HEADINGS = {"Index", "Active catalog", "Contents"}
# weak recall metadata and produces confident false-negatives for the "MemPalace to find, file to
# confirm" workflow (ersatztv#545).
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by", "signals")
EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
# The rationale-edit marker is a git TRAILER, not a bare substring anywhere in the commit range.
# ersatztv#609: the original `[decisions-edit]` substring test armed on ANY commit message containing
# the literal string — including prose *about* the mechanism, and including a message explaining why
# no token was needed — which silently disabled all three body-diff comparisons while the job still
# reported green. Git recognises a trailer only in a message's final trailer block, so prose can't
# arm it, and neither can a quoted example commit message (which docs commits here routinely carry).
EDIT_TRAILER = "Decisions-Edit"
_EDIT_TRAILER_AFFIRMATIVE = frozenset({"yes", "true", "1"})
_LEGACY_EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
# `date.fromisoformat` is NOT a YYYY-MM-DD validator: on Python >= 3.11 it also accepts ISO basic
@@ -162,7 +170,7 @@ def validate(
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")
errs.append(f"rationale prose of {h!r} changed without a '{EDIT_TRAILER}: yes' commit trailer")
for h in demoted:
errs.append(f"{h}: migrated record demoted to legacy-unmigrated (metadata block removed)")
@@ -252,6 +260,32 @@ def _rationale(rec) -> str:
return "\n".join(s for s in (ln.strip() for ln in rest) if s)
def _edit_trailer_armed(mb: str, head: str) -> bool:
"""True when some commit in `mb..head` carries an affirmative `Decisions-Edit:` git trailer.
A non-affirmative value (`no`, `not-needed`, …) deliberately does NOT arm the exemption: the
marker must be an explicit yes, so a trailer written to *record that no edit was intended* can
never disable the guard — the ersatztv#609 failure mode, one abstraction level up.
Git errors return False (the guard stays ON), matching the rest of the fail-safe posture here:
a lost marker costs a contributor one clear error message, a lost guard costs silent history
rewrites in the one PR that most needed policing.
"""
out = _run(["git", "log", f"--format=%(trailers:key={EDIT_TRAILER},valueonly)", f"{mb}..{head}"])
if out is not None and any(ln.strip().lower() in _EDIT_TRAILER_AFFIRMATIVE for ln in out.splitlines()):
return True
# Retired-form nudge: without it, a contributor using the pre-#609 habit gets a bare "prose
# changed" failure and no hint that the marker's FORM (not their intent) is what changed.
if _LEGACY_EDIT_TOKEN in (_run(["git", "log", "--format=%B", f"{mb}..{head}"]) or "").lower():
print(
f"::warning::decisions-validate: the commit range carries the retired "
f"{_LEGACY_EDIT_TOKEN} marker but no '{EDIT_TRAILER}: yes' trailer — since ersatztv#609 "
"the rationale-edit marker is a git trailer, and the substring no longer exempts anything",
file=sys.stderr,
)
return False
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)
@@ -261,10 +295,7 @@ def _diff_findings(base: str, head: str) -> tuple[list[str], list[str], list[str
file=sys.stderr,
)
return [], [], []
if EDIT_TOKEN.lower() in (_run(["git", "log", "--format=%B", f"{mb}..{head}"]) or "").lower():
token = True
else:
token = False
token = _edit_trailer_armed(mb, head)
base_active = _records_at(mb, _active_paths_at(mb))
head_active = _records_at(head, _active_paths_at(head))
+77 -3
View File
@@ -97,7 +97,7 @@ def test_removed_active_not_in_archive_fails():
def test_rewritten_rationale_without_token_fails():
assert any(
"rationale" in e and "decisions-edit" in e
"rationale" in e and "Decisions-Edit" in e
for e in _v([_rec(key="a.b")], rewritten=["2026-01-01 — Reworded (#9)"])
)
@@ -249,7 +249,12 @@ def test_diff_engine_detects_rationale_rewrite_without_token(tmp_path, monkeypat
assert "2026-01-01 — Some decision (#1)" in rewritten
def test_diff_engine_allows_rewrite_with_token(tmp_path, monkeypatch):
def _rewrite_repo(tmp_path: Path, monkeypatch, *commit_msg_paragraphs: str) -> tuple[list[str], list[str]]:
"""Seed a repo, rewrite the record's rationale under `commit_msg_paragraphs`, return (removed, rewritten).
Each paragraph is a separate `-m`, which is how git composes a message with a trailer block: the
LAST paragraph is the only one git parses for trailers (verified against git 2.55).
"""
repo = tmp_path / "repo"
(repo / "docs").mkdir(parents=True)
_git(tmp_path, "init", str(repo))
@@ -264,13 +269,82 @@ def test_diff_engine_allows_rewrite_with_token(tmp_path, monkeypatch):
_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]")
_git(repo, "commit", *[a for p in commit_msg_paragraphs for a in ("-m", p)])
removed, rewritten, *_rest = dv._diff_findings("HEAD~1", "HEAD")
return removed, rewritten
_RECORD = "2026-01-01 — Some decision (#1)"
def test_diff_engine_allows_rewrite_with_trailer(tmp_path, monkeypatch):
removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: yes")
assert removed == []
assert rewritten == []
def test_diff_engine_trailer_is_case_insensitive(tmp_path, monkeypatch):
_removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: YES")
assert rewritten == []
def test_diff_engine_trailer_coexists_with_coauthor(tmp_path, monkeypatch):
# the repo's `commit-msg` hook mandates a Co-Authored-By trailer, so the real-world shape of an
# armed commit is a multi-trailer block — the marker must survive sharing it.
_removed, rewritten = _rewrite_repo(
tmp_path,
monkeypatch,
"rewrite rationale",
"Decisions-Edit: yes\nCo-Authored-By: Someone <s@example.com>",
)
assert rewritten == []
def test_diff_engine_prose_mention_of_retired_token_does_not_arm(tmp_path, monkeypatch):
"""ersatztv#609's live failure: a message *describing* the marker disarmed the whole body-diff.
This is the negative control the bare-substring matcher could not pass — under it, `token` went
True and all three rewrite comparisons were skipped while CI still reported green.
"""
_removed, rewritten = _rewrite_repo(
tmp_path,
monkeypatch,
"docs: explain the guard",
"No [decisions-edit] marker is needed here, because this commit only moves a record.",
)
assert _RECORD in rewritten
def test_diff_engine_quoted_trailer_mid_body_does_not_arm(tmp_path, monkeypatch):
"""The same class one level up: a commit that QUOTES an armed message as an example.
Git parses trailers only in the final paragraph, so a `Decisions-Edit: yes` line followed by more
prose is documentation, not a marker. Docs commits in this repo routinely carry such examples.
"""
_removed, rewritten = _rewrite_repo(
tmp_path,
monkeypatch,
"docs: document how to arm the guard",
"Contributors write:",
"Decisions-Edit: yes",
"...as the final paragraph of the message, alongside Co-Authored-By.",
)
assert _RECORD in rewritten
def test_diff_engine_negative_trailer_value_does_not_arm(tmp_path, monkeypatch):
# `Decisions-Edit: no` records a deliberate NON-edit; reading it as consent would recreate #609.
_removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale", "Decisions-Edit: no")
assert _RECORD in rewritten
def test_diff_engine_retired_bracket_token_no_longer_arms(tmp_path, monkeypatch):
# the pre-#609 habit (token appended to the subject) is retired, not silently honored.
_removed, rewritten = _rewrite_repo(tmp_path, monkeypatch, "rewrite rationale [decisions-edit]")
assert _RECORD in rewritten
def test_diff_engine_detects_appended_smuggled_rationale(tmp_path, monkeypatch):
repo = tmp_path / "repo"
(repo / "docs").mkdir(parents=True)