# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/why-does-an-instance-fail-aaif-credential-binding-when-its-runtime-identity-is-re-derived-after-a-token-exchange.md
# Remnant Warrant:    https://corpusfetch.com/c/why-does-an-instance-fail-aaif-credential-binding-when-its-runtime-identity-is-re-derived-after-a-token-exchange.warrant.json
# Markdown SHA-256:   cfb0f20ebb160710b4f8d0eead90f6e0d33fff704ea72c6145bfc805acf32c2a
# Code AST SHA-256:   2c88c855b3c1074ed055966856f88b0b2ee8af7ddf47a9ad3b3c56f4ee33ffc7
# Verified Classes:   BindingDiscontinuityError, BindingRecord
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import hashlib
from dataclasses import dataclass
from typing import Optional


class BindingDiscontinuityError(RuntimeError):
    """Raised when a credential's presentation fingerprint diverges from its
    issuance fingerprint without an attested lifecycle transition."""
    pass


@dataclass(frozen=True)
class BindingRecord:
    subject: str
    audience: str
    issuance_fingerprint: str
    expires_at: float


# Attributes the orchestrator is permitted to mutate during a credential's
# lifetime. Including any of these in the binding fingerprint violates the
# continuity invariant.
MUTABLE_ATTRS = frozenset({"pod_uid", "hostname", "boot_nonce", "node_name", "ip"})


def compute_fingerprint(attrs: dict) -> str:
    # Only credential-scoped immutable material may enter the binding hash.
    immutable = {k: v for k, v in attrs.items() if k not in MUTABLE_ATTRS}
    if not immutable:
        raise BindingDiscontinuityError(
            "fingerprint derived solely from mutable attributes; "
            "continuity invariant cannot hold"
        )
    material = "|".join(f"{k}={immutable[k]}" for k in sorted(immutable))
    return hashlib.sha256(material.encode()).hexdigest()


def verify_presentation(
    record: BindingRecord,
    presented_fingerprint: str,
    now: float,
    lifecycle_transition: Optional[str] = None,
) -> None:
    if now > record.expires_at:
        raise BindingDiscontinuityError("credential expired")

    if presented_fingerprint == record.issuance_fingerprint:
        return

    # Mismatch: distinguish continuity gap from substitution.
    if lifecycle_transition in {"reschedule", "restore", "migration"}:
        # Continuity gap: benign, require re-attestation and re-mint.
        raise BindingDiscontinuityError(
            f"continuity gap during '{lifecycle_transition}'; "
            "re-attest and re-mint, do not revoke"
        )

    # No attested transition: treat as substitution.
    raise BindingDiscontinuityError(
        "binding mismatch with no lifecycle transition: "
        "possible credential substitution; revoke and escalate"
    )
