From 81dea1f35649e1fcec78eeccda1f3021000e47a6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 22:30:43 +0200 Subject: [PATCH] C-43: flag resolved decisions whose superseded predecessor is unlinked (STATE-WP-0097-T01) Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 356991@bnt-lap001 Assistant-Session: 5d99ea69-ed55-4a1e-8d97-f4f312db247f --- scripts/consistency_check.py | 58 +++++++++++++++++++ tests/test_consistency_check.py | 45 ++++++++++++++ ...-WP-0097-unlinked-decision-supersession.md | 11 +++- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 12880eb..ea4402d 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -46,6 +46,7 @@ Checks: C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4) C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins) C-42 workplan-blocked-no-wait WARN No blocked workplan has no task in status wait (CUST-WP-0074-T06) + C-43 decision-supersession-unlinked WARN No resolved decision of this repo says "supersedes " but the cited decision is not superseded_by it (STATE-WP-0097) (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) @@ -2478,6 +2479,7 @@ def check_repo( if scan_source_revision(repo_dir) != (report.source_revision, None): report.source_revision, report.source_revision_reason = None, "changed_during_scan" _check_inbox_hygiene(api_base, repo_slug, report) + _check_unlinked_supersession(api_base, set(file_ws_ids), report) _check_workplan_prefixes(repo_dir, repo_slug, workplan_infos, report) _check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report) _check_scope_freshness(repo_dir, workplan_infos, report) @@ -3047,6 +3049,62 @@ def _check_blocked_unblock_sweep( ) +_SUPERSEDES_RE = re.compile( + r"\bsupersedes\s+(?:decision\s+)?([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\b", + re.IGNORECASE, +) + + +def find_unlinked_supersessions( + decisions: list[dict], workplan_ids: set[str] +) -> list[tuple[dict, dict]]: + """(citing, cited) pairs where a resolved decision of these workplans says + "supersedes " but the cited decision is not superseded_by it. "Superseded + by" is the other direction and is not matched; ambiguous prefixes are skipped.""" + pairs: list[tuple[dict, dict]] = [] + for citing in decisions: + if str(citing.get("status") or "") != "resolved": + continue + if str(citing.get("workplan_id") or "") not in workplan_ids: + continue + text = " ".join(str(citing.get(k) or "") for k in ("title", "rationale", "description")) + for ref in dict.fromkeys(m.lower() for m in _SUPERSEDES_RE.findall(text)): + matches = [d for d in decisions if str(d.get("id") or "").lower().startswith(ref)] + if len(matches) != 1 or matches[0] is citing: + continue + cited = matches[0] + if ( + str(cited.get("status") or "") != "superseded" + or str(cited.get("superseded_by") or "") != str(citing["id"]) + ): + pairs.append((citing, cited)) + return pairs + + +def _check_unlinked_supersession( + api_base: str, workplan_ids: set[str], report: ConsistencyReport +) -> None: + workplan_ids = {str(w) for w in workplan_ids if w} + if not workplan_ids: + return + decisions = _api_get(api_base, "/decisions/") + if not isinstance(decisions, list): + return + for citing, cited in find_unlinked_supersessions(decisions, workplan_ids): + report.add( + severity="WARN", + check_id="C-43", + message=( + f"Decision {citing['id']} supersedes {cited['id']}, but the cited " + f"decision is status={cited.get('status')!r} " + f"superseded_by={cited.get('superseded_by')!r}; the decider should " + f"set status=superseded and superseded_by" + ), + db_id=str(cited["id"]), + fixable=False, + ) + + def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None: hygiene = collect_inbox_hygiene(api_base, repo_slug) if hygiene["stale_unread_count"]: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 7ba99e6..4dea13f 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -2615,3 +2615,48 @@ def test_decision_type_maps_onto_hub_values(meta, expected): from scripts.consistency_check import normalise_decision_type assert normalise_decision_type(meta) == expected + + +_OLD = "1c9817ee-95da-4165-985e-43fa61ec4827" +_NEW = "c6f23433-66c0-4ac9-af64-3e542706f213" +_WP = "1254b220-fd97-5c84-a0e0-ef2de10972c4" + + +def _supersession_pair(old_status, old_superseded_by, title="Owner (supersedes 1c9817ee)"): + return [ + {"id": _OLD, "status": old_status, "superseded_by": old_superseded_by, + "workplan_id": _WP, "title": "Owner", "rationale": None, "description": None}, + {"id": _NEW, "status": "resolved", "superseded_by": None, + "workplan_id": _WP, "title": title, "rationale": None, "description": None}, + ] + + +@pytest.mark.parametrize( + ("old_status", "old_superseded_by", "expected"), + [ + ("resolved", None, 1), # unlinked + ("superseded", None, 1), # status set, link missing + ("superseded", "0" * 32, 1), # linked to a different decision + ("superseded", _NEW, 0), # linked + ], +) +def test_unlinked_supersession_c43(old_status, old_superseded_by, expected): + from consistency_check import find_unlinked_supersessions + + pairs = find_unlinked_supersessions(_supersession_pair(old_status, old_superseded_by), {_WP}) + assert len(pairs) == expected + if expected: + assert pairs[0][0]["id"] == _NEW and pairs[0][1]["id"] == _OLD + + +def test_unlinked_supersession_c43_scope_and_direction(): + from consistency_check import find_unlinked_supersessions + + # Another repo's workplan is out of scope. + assert find_unlinked_supersessions(_supersession_pair("resolved", None), {"other"}) == [] + # "superseded by " is the opposite direction and is not a citation. + rows = _supersession_pair("resolved", None, title="Owner, superseded by 1c9817ee") + assert find_unlinked_supersessions(rows, {_WP}) == [] + # Full UUID citations match as well as 8-hex prefixes. + rows = _supersession_pair("resolved", None, title=f"Supersedes decision {_OLD}") + assert len(find_unlinked_supersessions(rows, {_WP})) == 1 diff --git a/workplans/STATE-WP-0097-unlinked-decision-supersession.md b/workplans/STATE-WP-0097-unlinked-decision-supersession.md index c4df291..f66d12e 100644 --- a/workplans/STATE-WP-0097-unlinked-decision-supersession.md +++ b/workplans/STATE-WP-0097-unlinked-decision-supersession.md @@ -4,7 +4,7 @@ type: workplan title: "Flag resolved decisions that a later decision supersedes without a link" domain: infotech repo: state-hub -status: proposed +status: active owner: claude-code topic_slug: infotech created: "2026-10-13" @@ -34,7 +34,7 @@ authoring surface. ```task id: STATE-WP-0097-T01 -status: todo +status: done priority: medium state_hub_task_id: "dd2d184a-9bfd-5578-b7ce-cc487f1a8a0a" ``` @@ -47,6 +47,13 @@ a deliberate founder/decider action: PATCH `status: superseded` and `superseded_by`. Add tests for the linked, unlinked and mismatched cases. Document C-43 in the checker header table. +2026-10-13 done: `find_unlinked_supersessions` and `_check_unlinked_supersession` +in `scripts/consistency_check.py`, scoped to decisions on the repo's own +workplans (one `GET /decisions/` per run). The check matches "supersedes +[decision] <8-hex|uuid>" and ignores "superseded by". It skips ambiguous +prefixes. Five new tests pass, and the full module passes (168 tests). +Live scan across all decisions: zero findings now that 1c9817ee is linked. + ## Document the interim contest convention ```task