"""Permission, drain, routing, and audit matrix for restart classes (#663).""" from __future__ import annotations import os from datetime import datetime, timezone import restart_coordinator as rc NOW = datetime(2026, 7, 24, 20, 0, tzinfo=timezone.utc) def _inventory() -> dict: return { "inventory_complete": True, "sessions": [ { "session_id": "requester", "role": "author", "profile": "prgs-author", "pid": os.getpid(), "status": "active", "last_heartbeat_at": NOW.isoformat(), }, { "session_id": "reviewer", "role": "reviewer", "profile": "prgs-reviewer", "pid": os.getpid(), "status": "active", "last_heartbeat_at": NOW.isoformat(), }, ], "leases": [ { "lease_id": "review-lease", "session_id": "reviewer", "role": "reviewer", "phase": "reviewing", "work_kind": "pr", "work_number": 900, "worktree_path": "/tmp/review-900", "freshness": {"freshness": "active"}, } ], } def _evaluate( restart_class: rc.RestartClass, *, role: str = "controller", permissions: tuple[str, ...] | None = None, approved: bool = True, operator: bool = True, **targets, ): return rc.evaluate_restart_impact( _inventory(), now=NOW, requesting_session_id="requester", restart_class=restart_class, requester_role=role, requester_permissions=( permissions if permissions is not None else rc.permissions_for_role(role) ), controller_approved=approved, operator_authorized=operator, **targets, ) def test_policy_table_covers_exactly_all_nine_classes(): assert set(rc.RESTART_CLASS_POLICIES) == set(rc.RestartClass) assert len(rc.RESTART_CLASS_POLICIES) == 9 for restart_class, policy in rc.RESTART_CLASS_POLICIES.items(): assert policy.restart_class is restart_class assert policy.required_permission assert policy.expected_blast_radius in { rc.BLAST_NONE, rc.BLAST_LOW, rc.BLAST_MEDIUM, rc.BLAST_HIGH } assert policy.drain_requirement assert policy.approval_requirement assert policy.audit_requirement assert policy.recovery_behavior def test_permission_matrix_allows_each_class_with_exact_permission(): targets = { rc.RestartClass.WORKER_RESTART: {"target_session_id": "reviewer"}, rc.RestartClass.ROLE_RUNTIME_RESTART: {"target_role": "reviewer"}, rc.RestartClass.CONNECTOR_RESTART: {"target_connector": "github"}, } for restart_class, policy in rc.RESTART_CLASS_POLICIES.items(): report = _evaluate( restart_class, permissions=(policy.required_permission,), **targets.get(restart_class, {}), ) assert report.permission_authorized, restart_class assert report.role_authorized, restart_class assert report.approval_satisfied, restart_class assert report.audit_record["restart_class"] == restart_class.value assert ( report.audit_record["required_permission"] == policy.required_permission ) def test_missing_or_nearby_permission_denies(): report = _evaluate( rc.RestartClass.ROLE_RUNTIME_RESTART, permissions=("mcp.restart.worker.request",), target_role="reviewer", ) assert report.verdict == rc.VERDICT_UNSAFE assert not report.allow_restart assert not report.permission_authorized assert any("missing required permission" in r for r in report.reasons) def test_unknown_restart_class_denies_fail_closed(): report = rc.evaluate_restart_impact( _inventory(), now=NOW, restart_class="surprise_reboot", requester_role="admin", requester_permissions=("mcp.restart.host.request",), controller_approved=True, operator_authorized=True, ) assert report.verdict == rc.VERDICT_UNSAFE assert not report.allow_restart assert report.restart_policy == {} assert any("unknown restart class" in r for r in report.reasons) def test_worker_roles_cannot_request_full_or_host_restart(): for role in rc.WORKER_ROLES: granted = rc.permissions_for_role(role) assert "mcp.restart.full.request" not in granted assert "mcp.restart.host.request" not in granted report = _evaluate( rc.RestartClass.FULL_MCP_RESTART, role=role, permissions=granted, ) assert not report.role_authorized assert not report.allow_restart def test_controller_approval_is_independent_of_permission(): report = _evaluate( rc.RestartClass.WORKER_RESTART, approved=False, target_session_id="reviewer", ) assert report.permission_authorized assert not report.approval_satisfied assert not report.allow_restart def test_narrow_classes_do_not_inherit_full_drain_or_peer_lease_block(): for restart_class in ( rc.RestartClass.CLIENT_RECONNECT, rc.RestartClass.SESSION_RECONNECT, rc.RestartClass.CONFIGURATION_RELOAD, ): report = _evaluate(restart_class) assert not report.restart_policy["full_drain_required"] assert report.counts["leases_disruptive"] == 0 assert report.counts["sessions_live_other"] == 0 assert report.counts["critical_sections"] == 0 assert report.counts["mutations"] == 0 assert report.allow_restart, (restart_class, report.reasons) def test_client_reconnect_does_not_wait_for_unrelated_terminal_lock(): inventory = _inventory() inventory["terminal_lock"] = {"terminal_pr": 901} report = rc.evaluate_restart_impact( inventory, now=NOW, requesting_session_id="requester", restart_class=rc.RestartClass.CLIENT_RECONNECT, requester_role="author", requester_permissions=rc.permissions_for_role("author"), ) assert report.allow_restart assert report.terminal_lock is None def test_scoped_restart_only_counts_named_target(): report = _evaluate( rc.RestartClass.ROLE_RUNTIME_RESTART, target_role="author", ) assert report.counts["leases_disruptive"] == 0 assert report.affected_prs == [] assert report.allow_restart reviewer = _evaluate( rc.RestartClass.ROLE_RUNTIME_RESTART, target_role="reviewer", ) assert reviewer.counts["leases_disruptive"] == 1 assert reviewer.affected_prs == [900] assert not reviewer.allow_restart def test_missing_scoped_target_denies_instead_of_widening(): for restart_class in ( rc.RestartClass.WORKER_RESTART, rc.RestartClass.ROLE_RUNTIME_RESTART, rc.RestartClass.CONNECTOR_RESTART, ): report = _evaluate(restart_class) assert not report.allow_restart assert any("target required" in r for r in report.reasons) def test_only_full_and_host_classes_require_full_drain(): requiring_full = { restart_class for restart_class, policy in rc.RESTART_CLASS_POLICIES.items() if policy.full_drain_required } assert requiring_full == { rc.RestartClass.FULL_MCP_RESTART, rc.RestartClass.HOST_RESTART, }