"""Deterministic leaver blast-radius and ownership-risk kernel. The batch adapter supplies authorised aggregate facts; this module turns them into a versioned, explainable result without a database or language model. """ from __future__ import annotations from dataclasses import dataclass from typing import Literal LEAVER_RISK_VERSION = "leaver-risk-v1" RiskLevel = Literal["low", "medium", "high", "critical"] RESOURCE_KINDS: frozenset[str] = frozenset({ "account", "permission", "api_key", "seat", "asset", "cloud_scope", "server", "domain", "email_group", "data_handover", # ``system`` = internal system registry ownership (2026-08-25). Its # own kind rather than folded into ``server``: a registry entry is an # application someone is accountable for, not a machine, and the # blast-radius count should not imply a host was left running. "system", }) @dataclass(frozen=True, slots=True) class LeaverRiskInput: """One leaver's aggregate exposure, with no person or object identity.""" resource_counts: tuple[tuple[str, int], ...] = () pending_count: int = 0 overdue_count: int = 0 missing_handover_count: int = 0 privileged_count: int = 0 successor_available: bool = True data_handover_complete: bool = True blocked: bool = False def __post_init__(self) -> None: seen: set[str] = set() for kind, count in self.resource_counts: if kind not in RESOURCE_KINDS: raise ValueError(f"unsupported leaver resource kind: {kind}") if kind in seen: raise ValueError(f"duplicate leaver resource kind: {kind}") if count < 0: raise ValueError("resource counts cannot be negative") seen.add(kind) for name in ("pending_count", "overdue_count", "missing_handover_count", "privileged_count"): if getattr(self, name) < 0: raise ValueError(f"{name} cannot be negative") if self.overdue_count > self.pending_count: raise ValueError("overdue_count cannot exceed pending_count") if self.missing_handover_count > sum(count for _, count in self.resource_counts): raise ValueError("missing handovers cannot exceed resource exposure") @dataclass(frozen=True, slots=True) class LeaverRiskResult: score: int level: RiskLevel blast_radius: int pending_count: int overdue_count: int missing_handover_count: int privileged_count: int resource_counts: tuple[tuple[str, int], ...] successor_available: bool data_handover_complete: bool blocked: bool reasons: tuple[str, ...] risk_version: str = LEAVER_RISK_VERSION def _level(score: int) -> RiskLevel: if score >= 80: return "critical" if score >= 55: return "high" if score >= 25: return "medium" return "low" def build_leaver_risk(value: LeaverRiskInput) -> LeaverRiskResult: """Calculate a bounded score from facts, never from model output.""" resources = tuple(sorted(value.resource_counts)) blast_radius = sum(count for _, count in resources) score = min(35, blast_radius * 2) score += min(25, value.pending_count * 5) score += min(20, value.overdue_count * 10) score += min(15, value.privileged_count * 5) score += min(20, value.missing_handover_count * 10) reasons: list[str] = [] if not value.successor_available: score = max(score, 80) reasons.append("successor_missing") if not value.data_handover_complete: score = max(score, 70) reasons.append("data_handover_incomplete") if value.overdue_count: reasons.append("pending_items_overdue") if value.privileged_count: reasons.append("privileged_access_present") if value.missing_handover_count: reasons.append("ownership_handover_missing") if value.blocked: score = max(score, 80) reasons.append("offboarding_blocked") score = min(100, score) return LeaverRiskResult( score=score, level=_level(score), blast_radius=blast_radius, pending_count=value.pending_count, overdue_count=value.overdue_count, missing_handover_count=value.missing_handover_count, privileged_count=value.privileged_count, resource_counts=resources, successor_available=value.successor_available, data_handover_complete=value.data_handover_complete, blocked=value.blocked, reasons=tuple(reasons), ) def aggregate_leaver_risk(values: tuple[LeaverRiskInput, ...]) -> dict[str, int]: """Produce platform-level totals without leaking individual leavers.""" results = tuple(build_leaver_risk(value) for value in values) return { "leaver_count": len(results), "critical_count": sum(result.level == "critical" for result in results), "high_count": sum(result.level == "high" for result in results), "blocked_count": sum(result.blocked for result in results), "pending_count": sum(result.pending_count for result in results), "overdue_count": sum(result.overdue_count for result in results), "blast_radius": sum(result.blast_radius for result in results), "missing_handover_count": sum(result.missing_handover_count for result in results), "privileged_count": sum(result.privileged_count for result in results), }