feat(gateway): roll-up AC-verification gate (guardrails spec 4/4)

A parent could complete / submit_up / escalate_to_ceo once its subtasks were
merely terminal — never checking whether the parent's acceptance criteria were
actually satisfied. That's how PR #175's half-built umbrella sailed to CEO
approval (escalate_to_ceo had no subtask/AC check at all).

- TaskService.uncovered_parent_acceptance_criteria(parent): parent ACs not
  covered by a COMPLETED child (via parent_ac_refs). Safe-by-construction —
  returns [] unless a child declares coverage, so it is INERT for tasks
  decomposed before coverage tracking and activates only once a PM maps
  children to parent criteria. Cancelled children do not count.
- _parent_acs_covered_envelope wired into all four roll-up gates: cell_pm_complete,
  main_pm_complete, submit_up, and escalate_to_ceo (the weakest — previously
  only journal:decision). isinstance guard keeps it inert under partial mocks.
- 4 new tests; 57 task + 89 gateway tests green.

Pairs with spec 2 (coverage at decompose-time forces the linkage this enforces).
This commit is contained in:
Renn F
2026-06-16 03:18:17 +02:00
parent 87ca142f4e
commit 0fd9aee88d
3 changed files with 147 additions and 6 deletions
+51
View File
@@ -621,6 +621,57 @@ async def test_create_generates_ac_ids_and_carries_parent_ac_refs() -> None:
assert list(task.parent_ac_refs) == ["parent-ac-1", "parent-ac-2"]
def _svc_with_children(parent: object, child_rows: list[tuple]) -> TaskService:
"""TaskService whose get() returns `parent` and whose execute() yields the
(status, parent_ac_refs) child rows the coverage primitive selects."""
rows = MagicMock()
rows.all.return_value = child_rows
svc = TaskService(MagicMock(execute=AsyncMock(return_value=rows)))
_bind(svc, "get", AsyncMock(return_value=parent))
return svc
@pytest.mark.asyncio
async def test_uncovered_parent_acs_inert_without_declared_coverage() -> None:
# No child declares parent_ac_refs -> coverage tracking inactive -> the gate
# is inert (legacy/in-flight tasks are never blocked).
parent = _build_task(
acceptance_criteria=["a", "b"], acceptance_criteria_ids=["id-a", "id-b"]
)
svc = _svc_with_children(
parent, [(TaskStatus.COMPLETED, []), (TaskStatus.COMPLETED, [])]
)
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_uncovered_parent_acs_flags_unsatisfied_and_ignores_cancelled() -> None:
parent = _build_task(
acceptance_criteria=["crit a", "crit b", "crit c"],
acceptance_criteria_ids=["id-a", "id-b", "id-c"],
)
svc = _svc_with_children(
parent,
[
(TaskStatus.COMPLETED, ["id-a"]), # covers crit a
(TaskStatus.CANCELLED, ["id-b"]), # cancelled -> does NOT cover crit b
],
)
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == [
"crit b",
"crit c",
]
@pytest.mark.asyncio
async def test_uncovered_parent_acs_empty_when_all_covered() -> None:
parent = _build_task(
acceptance_criteria=["a", "b"], acceptance_criteria_ids=["id-a", "id-b"]
)
svc = _svc_with_children(parent, [(TaskStatus.COMPLETED, ["id-a", "id-b"])])
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_unblock_with_branch_resumes_in_progress() -> None:
# A task claimed (has a branch) before it blocked resumes in_progress.