[chore] logical-gaps: lifecycle-enforcement validators + status-class fixes (5 gaps)

enforcement/task_lifecycle.py:
- drop the spurious VERIFYING->awaiting_documentation legacy edge. The
  canonical exit is submit_qa -> awaiting_qa -> (qa_pass) ->
  awaiting_documentation; the direct edge bypassed the entire QA review hop
  (ungated — no role gate existed for it).
- is_waiting_state: add awaiting_pr_review. The PR-review gate parks the PM on
  the reviewer; it is a waiting state. The hard-coded set was never updated
  when AWAITING_PR_REVIEW was added to the enum, so the gate status was
  miscategorized as active.

foundation/_validate_lifecycle.py:
- _check_status_enum_coverage: replace the tautology (STATUS_GRAPH keys every
  Status by construction) with a real bidirectional check — every non-terminal
  Status is the source of a transition (catches orphan states), and every
  source/target referenced is a real Status member (catches stray-string
  targets).
- _check_terminal_exits: split the {COMPLETED, CANCELLED} reachability into a
  COMPLETED-path requirement + a cancel-exit requirement. The cancel fan-out
  made the old check structurally trivial — a status whose sole exit was cancel
  passed with no real forward completion path.
- _check_status_enum_parity (new, registered): cross-check spec.Status against
  models.base.TaskStatus at import so the ORM column type and the lifecycle
  map cannot drift (TaskType had this guard; Status did not).

tests: verifying->awaiting_documentation rejected, self-fail preserved,
awaiting_pr_review is waiting, mutually-disjoint classification invariant,
status enum parity, stray-string-target / orphan-source / cancel-only-exit
validator rejections.
This commit is contained in:
Renn F
2026-06-30 13:01:40 +02:00
parent c71f9b3b01
commit ef33d56cf9
4 changed files with 186 additions and 13 deletions
+68
View File
@@ -11,6 +11,7 @@ from roboco.foundation import _validate_lifecycle as _validate
from roboco.foundation._validate_lifecycle import reachable_from
from roboco.foundation.policy import lifecycle as spec
from roboco.foundation.policy.lifecycle import _INTENT_VERBS, IntentSpec
from roboco.models.base import TaskStatus as ModelTaskStatus
from roboco.models.base import TaskType as ModelTaskType
@@ -79,6 +80,73 @@ def test_task_type_enum_matches_models() -> None:
)
def test_status_enum_matches_models() -> None:
"""The spec's Status must match models.base.TaskStatus — the ORM column
type and the lifecycle map must not drift. TaskType has this guard; Status
did not, so adding/renaming a status in one enum only wedged silently."""
spec_values = {s.value for s in spec.Status}
model_values = {s.value for s in ModelTaskStatus}
assert spec_values == model_values, (
f"Status drift between lifecycle.spec and models.base: "
f"{spec_values ^ model_values}"
)
def test_status_enum_parity_validator_passes_on_real_spec() -> None:
"""The import-time parity validator must agree with the real enums."""
_validate._check_status_enum_parity() # no raise
def test_status_coverage_rejects_stray_string_target() -> None:
"""A transition referencing a non-Status target string must fail the
coverage validator — the old check was a tautology (STATUS_GRAPH keys every
Status by construction) and let stray-string targets through."""
fake = (
SimpleNamespace(
source=spec.Status.PENDING, target="bogus_state", triggered_by_action="x"
),
)
original = spec._STATUS_TRANSITIONS
spec._STATUS_TRANSITIONS = fake # type: ignore[attr-defined]
try:
with pytest.raises(_validate.LifecycleSpecError, match="non-Status"):
_validate._check_status_enum_coverage()
finally:
spec._STATUS_TRANSITIONS = original # type: ignore[attr-defined]
def test_status_coverage_rejects_orphan_non_terminal_source() -> None:
"""A non-terminal status that is the source of no transition (an orphan
state) must fail the coverage validator — the cancel fan-out made the old
'is a key in STATUS_GRAPH' check structurally always-true."""
original = spec._STATUS_TRANSITIONS
spec._STATUS_TRANSITIONS = tuple( # type: ignore[attr-defined]
t for t in original if t.source is not spec.Status.PAUSED
)
try:
with pytest.raises(
_validate.LifecycleSpecError, match="no outgoing transition"
):
_validate._check_status_enum_coverage()
finally:
spec._STATUS_TRANSITIONS = original # type: ignore[attr-defined]
def test_terminal_exit_requires_a_completed_path() -> None:
"""Every non-terminal status must reach COMPLETED specifically — the cancel
fan-out made the old {COMPLETED, CANCELLED} check trivial, so a status whose
sole exit was cancel passed the guard with no real forward completion path."""
original = spec.STATUS_GRAPH
fake = dict(original)
fake[spec.Status.PAUSED] = frozenset({spec.Status.CANCELLED})
spec.STATUS_GRAPH = fake # type: ignore[attr-defined]
try:
with pytest.raises(_validate.LifecycleSpecError, match="no path to COMPLETED"):
_validate._check_terminal_exits()
finally:
spec.STATUS_GRAPH = original # type: ignore[attr-defined]
def test_decision_allow_has_no_rejection_kind() -> None:
d = spec.Decision.allow()
assert d.allowed is True
@@ -17,6 +17,7 @@ from roboco.enforcement.task_lifecycle import (
validate_task_transition,
)
from roboco.exceptions import TaskLifecycleError
from roboco.foundation.policy.lifecycle import Status
# ---------------------------------------------------------------------------
# validate_task_transition
@@ -231,3 +232,46 @@ def test_validate_cancel_from_awaiting_ceo_raises_for_pm() -> None:
validate_task_transition("awaiting_ceo_approval", "cancelled", "cell_pm")
# CEO is allowed — no raise.
assert validate_task_transition("awaiting_ceo_approval", "cancelled", "ceo") is True
# ---------------------------------------------------------------------------
# VERIFYING must go through the QA hop (awaiting_qa), not straight to docs
# ---------------------------------------------------------------------------
def test_verifying_to_awaiting_documentation_is_rejected() -> None:
"""VERIFYING is the dev self-verification state; the canonical exit is
submit_qa -> awaiting_qa -> (qa_pass) -> awaiting_documentation. A spurious
verifying->awaiting_documentation edge bypassed the entire QA review hop."""
with pytest.raises(TaskLifecycleError):
validate_task_transition("verifying", "awaiting_documentation", "qa")
def test_verifying_self_fail_to_needs_revision_still_allowed() -> None:
"""The legitimate self-fail out of verifying (QA/PM only) is preserved."""
assert validate_task_transition("verifying", "needs_revision", "qa") is True
# ---------------------------------------------------------------------------
# is_waiting_state must cover the in-path PR-review gate
# ---------------------------------------------------------------------------
def test_is_waiting_state_includes_awaiting_pr_review() -> None:
"""The PR-review gate parks the PM on the reviewer; it is a waiting state,
not an active one (the predicates were never updated when AWAITING_PR_REVIEW
was added to the enum)."""
assert is_waiting_state("awaiting_pr_review") is True
assert is_active_state("awaiting_pr_review") is False
def test_status_classification_is_mutually_disjoint() -> None:
"""No status may be classified as both active and waiting — the structural
invariant that catches miscategorization (the awaiting_pr_review leak was
an instance of a status falling into no category)."""
active = {s.value for s in Status if is_active_state(s.value)}
waiting = {s.value for s in Status if is_waiting_state(s.value)}
terminal = {s.value for s in Status if is_terminal_state(s.value)}
assert active & waiting == set()
assert active & terminal == set()
assert waiting & terminal == set()