C-43: flag resolved decisions whose superseded predecessor is unlinked (STATE-WP-0097-T01)
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:
parent
d60f2647d4
commit
81dea1f356
3 changed files with 112 additions and 2 deletions
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue