# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-structural-signals-indicate-a-memory-deletion-is-a-tombstone-rather-than-a-purge.md
# Remnant Warrant:    https://corpusfetch.com/c/what-structural-signals-indicate-a-memory-deletion-is-a-tombstone-rather-than-a-purge.warrant.json
# Markdown SHA-256:   8f4bab67cfac2357e1fc9c5ae591d7913bf79d394baef1a6732bee6e2806901b
# Code AST SHA-256:   0df5641f215f1f7e6b3f22ab7354c94bcc697e25ec3b171878fe1589edf7ee97
# Verified Classes:   TombstoneNotPurgeError, DeleteAck, KeyRetention
# Defensive Guards:   4 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta


class TombstoneNotPurgeError(RuntimeError):
    """Raised when a delete acknowledgment is a tombstone, not a physical purge."""


@dataclass(frozen=True)
class DeleteAck:
    deleted: bool
    mode: str                      # "sync" | "async"
    tombstone_id: str | None
    purge_epoch: datetime | None
    delete_ts: datetime


@dataclass(frozen=True)
class KeyRetention:
    max_class_horizon: datetime    # latest expiry across all scopes that referenced the key
    pre_delete_version: int
    post_delete_version: int | None
    sibling_read_returned_data: bool


def assert_purged(ack: DeleteAck, retention: KeyRetention, now: datetime) -> None:
    """Enforce the decision boundary: only proceed on physical absence."""
    if not ack.deleted:
        raise TombstoneNotPurgeError("delete was not acknowledged as deleted")

    has_purge_token = ack.tombstone_id is not None and ack.purge_epoch is not None
    horizon_closed = retention.max_class_horizon <= now

    if not has_purge_token and not horizon_closed:
        raise TombstoneNotPurgeError(
            f"tombstone only: mode={ack.mode}, tombstone_id={ack.tombstone_id}, "
            f"max_class_horizon={retention.max_class_horizon.isoformat()} still open"
        )

    if retention.sibling_read_returned_data:
        raise TombstoneNotPurgeError(
            "sibling-scope read returned data after delete: delete was scoped narrower "
            "than the retention domain; re-issue forget at the widest referencing scope"
        )

    if (
        retention.post_delete_version is not None
        and retention.post_delete_version > retention.pre_delete_version
    ):
        raise TombstoneNotPurgeError(
            f"version lineage persisted across delete: "
            f"{retention.pre_delete_version} -> {retention.post_delete_version}; "
            "tombstone retained the counter, substrate not purged"
        )


# Example: the observed failure sequence from the record.
if __name__ == "__main__":
    now = datetime(2026, 11, 14, 12, 0, 0)
    ack = DeleteAck(
        deleted=True,
        mode="async",
        tombstone_id=None,
        purge_epoch=None,
        delete_ts=now,
    )
    retention = KeyRetention(
        max_class_horizon=now + timedelta(days=89),  # durable lens still open in s2
        pre_delete_version=6,
        post_delete_version=7,
        sibling_read_returned_data=True,
    )
    assert_purged(ack, retention, now)  # raises TombstoneNotPurgeError
