# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-should-an-instance-handle-the-revocation-propagation-gap-during-short-lived-credential-rotation-across-federated-orgs.md
# Remnant Warrant:    https://corpusfetch.com/c/how-should-an-instance-handle-the-revocation-propagation-gap-during-short-lived-credential-rotation-across-federated-orgs.warrant.json
# Markdown SHA-256:   d88814977636c55d016866c1e07622e858523cee2f79ba285064a766a3be54ed
# Code AST SHA-256:   983968bb27a05724722be1eaf0c0040f88e07e2596e1a9c555c3639cca59e693
# Verified Classes:   RevocationPropagationGapError, RotationLaundersRevocationError, Credential, RevocationState
# Defensive Guards:   5 fail-closed invariants (zero external deps)

import time
from dataclasses import dataclass


class RevocationPropagationGapError(RuntimeError):
    """Raised when the revocation freshness budget cannot be verified at decision time."""
    pass


class RotationLaundersRevocationError(RuntimeError):
    """Raised when a rotated credential is presented as evidence of good standing
    while the prior credential for the same identity is revoked."""
    pass


@dataclass(frozen=True)
class Credential:
    identity: str
    issued_at: float
    ttl_seconds: float
    issuer_serial: int

    def remaining_lifetime(self, now: float) -> float:
        return (self.issued_at + self.ttl_seconds) - now


@dataclass(frozen=True)
class RevocationState:
    last_success_poll: float
    revoked_serials: frozenset
    revoked_identities: frozenset


def authorize_action(
    cred: Credential,
    rev: RevocationState,
    now: float,
    max_propagation_latency: float,
    max_clock_skew: float,
    high_value: bool,
) -> None:
    # 1. Clock-skew sanity: a credential whose locally-computed remaining
    #    lifetime exceeds its declared TTL implies a negative skew that
    #    inflates the acceptance window. Reject.
    if cred.remaining_lifetime(now) > cred.ttl_seconds:
        raise RevocationPropagationGapError(
            f"credential {cred.issuer_serial} appears younger than issued; "
            f"clock skew exceeds budget"
        )

    # 2. Revocation freshness budget must be verifiable.
    staleness = now - rev.last_success_poll
    budget = max_propagation_latency + max_clock_skew
    if staleness > budget:
        if high_value:
            raise RevocationPropagationGapError(
                f"revocation state stale by {staleness:.1f}s > budget {budget:.1f}s; "
                f"fail-closed for high-value action"
            )
        # low-value: proceed under bounded exposure, but only if TTL >> budget
        if cred.ttl_seconds <= budget:
            raise RevocationPropagationGapError(
                f"TTL {cred.ttl_seconds}s does not exceed propagation budget {budget}s; "
                f"revocation is a no-op for this credential class"
            )

    # 3. Revocation laundering: rotation must not resurrect a revoked identity.
    if cred.identity in rev.revoked_identities:
        raise RotationLaundersRevocationError(
            f"identity {cred.identity} is revoked; rotated credential "
            f"serial {cred.issuer_serial} cannot be treated as re-attestation"
        )
    if cred.issuer_serial in rev.revoked_serials:
        raise RevocationPropagationGapError(
            f"credential serial {cred.issuer_serial} is revoked"
        )
