diff --git a/api/schemas/workplan.py b/api/schemas/workplan.py index 20b6d4d..eb71618 100644 --- a/api/schemas/workplan.py +++ b/api/schemas/workplan.py @@ -155,6 +155,9 @@ class WorkplanRead(WorkplanStatusMixin): backing_relative_path: str | None = None backing_archived: bool | None = None backing_synced_at: datetime | None = None + # Set when forge projection retired the row (slug stamped "@retired-"). + # A retired row is a tombstone, not live work, whatever its status. + projection_retired_at: datetime | None = None # Derived (CUST-WP-0074 rule 5): "human" | "external" | "none" while # status is blocked, else None. Set by # api.services.wait_kind.annotate_workplans on read routes. diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 33e4906..12880eb 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -13,7 +13,7 @@ Checks: C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id --fix derives the UUID from the canonical record id and writes it back C-07 orphan-db-active FAIL No Active DB workstream, no backing file - C-08 orphan-db-closed INFO No Finished/archived DB workstream, no file + C-08 orphan-db-closed INFO No Finished/archived (or projection-retired) DB workstream, no file C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location C-10 task-status-drift WARN Yes Task status differs between file and DB C-11 task-unlinked WARN Yes Task block has no state_hub_task_id @@ -2522,6 +2522,14 @@ def _sync_workplan_bindings( ) +RETIRED_SLUG_MARK = "@retired-" # mirrors api.services.forge_projection + + +def _is_projection_retired(ws: dict) -> bool: + """True for a row forge projection retired (field, or the slug stamp on older APIs).""" + return bool(ws.get("projection_retired_at")) or RETIRED_SLUG_MARK in (ws.get("slug") or "") + + def _check_orphan_db( api_base: str, repo_id: str, @@ -2529,7 +2537,15 @@ def _check_orphan_db( report: ConsistencyReport, active_file_ws_ids: set[str] | None = None, ) -> None: - """Flag DB workstreams with repo_id=this_repo that have no backing workplan file.""" + """Flag DB workstreams with repo_id=this_repo that have no backing workplan file. + + A row retired by forge projection is a tombstone: retirement stamps the + slug with ``@retired-YYYYMMDD`` and sets ``projection_retired_at`` but + deliberately leaves status alone, so a row retired while blocked stays + blocked. Its canonical successor is file-backed under a new id. Such rows + are reported as C-08 INFO, never C-07 FAIL — the repo has nothing to fix, + and closing them by hand would rewrite history the retirement preserved. + """ active_file_ws_ids = active_file_ws_ids or file_ws_ids all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) if not isinstance(all_ws, list): @@ -2543,6 +2559,18 @@ def _check_orphan_db( if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids: continue ws_slug = ws.get("slug", "") + if _is_projection_retired(ws) and ws_id not in file_ws_ids: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Retired DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file " + f"— projection tombstone, not live work" + ), + db_id=ws_id, + fixable=False, + ) + continue if normalised_status not in CLOSED_WORKSTREAM_STATUSES: report.add( severity="FAIL", check_id="C-07", diff --git a/tests/test_c07_retired_rows.py b/tests/test_c07_retired_rows.py new file mode 100644 index 0000000..ecb9ca4 --- /dev/null +++ b/tests/test_c07_retired_rows.py @@ -0,0 +1,39 @@ +"""C-07 must not fail on rows forge projection retired (railiance-platform, 2026-10-11). + +Retirement stamps the slug with ``@retired-YYYYMMDD`` and sets +``projection_retired_at`` but leaves status untouched, so a row retired while +blocked stayed blocked and C-07 reported it as an ADR-001 violation the repo +could never fix. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +import consistency_check # noqa: E402 +from consistency_check import ConsistencyReport, _check_orphan_db # noqa: E402 + + +def _run(monkeypatch, rows, file_ids=frozenset()): + monkeypatch.setattr(consistency_check, "_api_get", lambda *a, **k: rows) + report = ConsistencyReport(repo_slug="r", repo_path="/tmp/r") + _check_orphan_db("http://hub", "repo-id", set(file_ids), report) + return [(i.severity, i.check_id) for i in report.issues] + + +def test_retired_slug_blocked_row_is_info_not_fail(monkeypatch): + rows = [{"id": "038bc3c0-0000", "slug": "railiance-wp-0029@retired-20260826", "status": "blocked"}] + assert _run(monkeypatch, rows) == [("INFO", "C-08")] + + +def test_projection_retired_at_field_is_info_not_fail(monkeypatch): + rows = [{"id": "aaaa0000-0000", "slug": "x-wp-0001", "status": "active", + "projection_retired_at": "2026-08-26T00:00:00Z"}] + assert _run(monkeypatch, rows) == [("INFO", "C-08")] + + +def test_unretired_open_orphan_still_fails(monkeypatch): + rows = [{"id": "bbbb0000-0000", "slug": "x-wp-0002", "status": "blocked"}] + assert _run(monkeypatch, rows) == [("FAIL", "C-07")]