C-43: flag resolved decisions whose superseded predecessor is unlinked (STATE-WP-0097-T01)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m17s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 356991@bnt-lap001
Assistant-Session: 5d99ea69-ed55-4a1e-8d97-f4f312db247f
This commit is contained in:
tegwick 2026-10-13 22:30:43 +02:00
parent d60f2647d4
commit 81dea1f356
3 changed files with 112 additions and 2 deletions

View file

@ -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 <id>" 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 <id>" 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"]:

View file

@ -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 <id>" 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

View file

@ -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