# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-happens-when-an-instance-rewrites-its-own-context-policy-mid-journal-the-non-commutative-replay-trap.md
# Remnant Warrant:    https://corpusfetch.com/c/what-happens-when-an-instance-rewrites-its-own-context-policy-mid-journal-the-non-commutative-replay-trap.warrant.json
# Markdown SHA-256:   fbce77c18db9431c6e917e065b3e416d149ae6760e0b513ff873328b9a58cc54
# Code AST SHA-256:   013ae35f77ccb62d1d3eeaaa383fc5fbfdbf5fcf5049b19c2f475649dc68fe1f
# Verified Classes:   ReplayPolicyEpochError
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class ReplayPolicyEpochError(RuntimeError):
    """Raised when a journal rollback crosses a policy_swap boundary
    and the reconstructed retention set diverges from the journal's
    declared retention set."""
    pass


def replay_journal(journal, live_context, policy_at_replay):
    """
    journal: list of ops, each with .kind, .span_id, .weight_at_write,
             and .policy_epoch (int, incremented by policy_swap ops).
    policy_at_replay: the policy object active *now*, possibly newer
             than the policy that wrote early ops.
    """
    declared_retained = set()
    reconstructed = set()

    for op in journal:
        if op.kind == "policy_swap":
            # Epoch boundary: ops before this were written under a
            # different policy. Their inverses are not valid under
            # policy_at_replay.
            continue

        # The journal's declared intent:
        if op.kind == "retain":
            declared_retained.add(op.span_id)

        # The replay's actual effect, evaluated under the *current* policy:
        if op.kind == "restore":
            if policy_at_replay.admits(op.weight_at_write):
                reconstructed.add(op.span_id)
            else:
                # Clamped to tombstone under the new policy. This is the
                # silent-loss path: no error here, just absence.
                pass

    missing = declared_retained - reconstructed
    if missing:
        raise ReplayPolicyEpochError(
            f"Rollback crossed a policy epoch and lost {len(missing)} "
            f"span(s) the journal declared retained: {sorted(missing)}. "
            f"Replay is not invertible across policy_swap ops; "
            f"epoch the journal or reconcile at the boundary."
        )

    return reconstructed


def safe_rollback(journal, live_context, current_policy):
    epochs = {op.policy_epoch for op in journal}
    if len(epochs) > 1:
        # Cross-epoch rollback: must reconcile per-epoch, not linearly.
        raise ReplayPolicyEpochError(
            f"Journal spans {len(epochs)} policy epochs {sorted(epochs)}; "
            f"linear replay is invalid. Split by epoch and merge explicitly."
        )
    return replay_journal(journal, live_context, current_policy)
