# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-aaif-scoped-agent-permissions-from-being-silently-widened-at-runtime-enforcement-points.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-aaif-scoped-agent-permissions-from-being-silently-widened-at-runtime-enforcement-points.warrant.json
# Markdown SHA-256:   413ff48163e846563635b48ed892cb9a527da380a7fe7b627d59a2bcca385651
# Code AST SHA-256:   4ea75e74030c24a0c9ef0ccd5aa4ff6f9c6a0b53d39bb57c25b9ace6aae284b3
# Verified Classes:   ScopeContainmentError, AAIFGrant, EnforcementPoint
# Defensive Guards:   3 fail-closed invariants (zero external deps)

import posixpath
from dataclasses import dataclass, field
from typing import FrozenSet


class ScopeContainmentError(RuntimeError):
    """Raised when a PEP admits or fails to deny an operation outside the AAIF grant."""
    pass


@dataclass(frozen=True)
class AAIFGrant:
    agent_id: str
    terms: FrozenSet[str] = field(default_factory=frozenset)
    revocation_generation: int = 0


def canonicalize(term: str) -> str:
    # Structural canonical form: no trailing slash, no '..' segments, no case drift.
    if term.startswith("fs."):
        _, rest = term.split(".", 1)
        action, _, path = rest.partition(":")
        return f"fs.{action}:{posixpath.normpath(path)}"
    return term.strip()


class EnforcementPoint:
    def __init__(self, grant: AAIFGrant):
        self._grant = grant
        self._canonical_terms = frozenset(canonicalize(t) for t in grant.terms)
        self._decision_generation = grant.revocation_generation

    def _authorizing_term(self, request: str) -> str | None:
        canon = canonicalize(request)
        # Exact structural membership only. No prefix, substring, or glob fallback.
        return canon if canon in self._canonical_terms else None

    def admit(self, request: str, current_revocation_generation: int) -> None:
        if current_revocation_generation != self._decision_generation:
            raise ScopeContainmentError(
                f"revocation generation advanced for agent {self._grant.agent_id}; "
                f"cached decision is stale and must be re-derived"
            )
        term = self._authorizing_term(request)
        if term is None:
            # Distinct out-of-scope denial; never a silent or generic refusal.
            raise ScopeContainmentError(
                f"agent {self._grant.agent_id} requested {canonicalize(request)!r}, "
                f"which is not an enumerated term in the AAIF grant"
            )
        return None


def reconcile_decision_log(grant: AAIFGrant, admitted_requests: list[str]) -> None:
    """Continuous invariant check: every admitted op must map to a grant term."""
    canonical_terms = frozenset(canonicalize(t) for t in grant.terms)
    for req in admitted_requests:
        if canonicalize(req) not in canonical_terms:
            raise ScopeContainmentError(
                f"decision log contains admitted operation {canonicalize(req)!r} "
                f"with no authorizing term in grant for agent {grant.agent_id}"
            )
