lethe-delete 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lethe/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .core import Lethe
2
+ from .models import Certificate, LayerResult, TagRecord
3
+ from .version import __version__
4
+
5
+ __all__ = ["__version__", "Lethe", "Certificate", "LayerResult", "TagRecord"]
lethe/audit.py ADDED
@@ -0,0 +1,83 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import psycopg
6
+
7
+ GENESIS = "0" * 64
8
+
9
+ AUDIT_SCHEMA = """
10
+ CREATE TABLE IF NOT EXISTS lethe_audit (
11
+ seq BIGSERIAL PRIMARY KEY,
12
+ entry JSONB NOT NULL,
13
+ prev_hash TEXT NOT NULL,
14
+ entry_hash TEXT NOT NULL,
15
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
16
+ );
17
+ """
18
+
19
+
20
+ def _entry_hash(prev_hash: str, entry: dict) -> str:
21
+ data = prev_hash + json.dumps(entry, sort_keys=True, separators=(",", ":"))
22
+ return hashlib.sha256(data.encode()).hexdigest()
23
+
24
+
25
+ class AuditLog:
26
+ def __init__(self, conn: psycopg.Connection):
27
+ self.conn = conn
28
+
29
+ def init_schema(self) -> None:
30
+ with self.conn.cursor() as cur:
31
+ cur.execute(AUDIT_SCHEMA)
32
+ self.conn.commit()
33
+
34
+ def _last_hash(self) -> str:
35
+ with self.conn.cursor() as cur:
36
+ cur.execute("SELECT entry_hash FROM lethe_audit ORDER BY seq DESC LIMIT 1")
37
+ row = cur.fetchone()
38
+ return row[0] if row else GENESIS
39
+
40
+ def append(self, entry: dict) -> str:
41
+ prev = self._last_hash()
42
+ h = _entry_hash(prev, entry)
43
+ with self.conn.cursor() as cur:
44
+ cur.execute(
45
+ "INSERT INTO lethe_audit (entry, prev_hash, entry_hash) VALUES (%s, %s, %s)",
46
+ (json.dumps(entry), prev, h),
47
+ )
48
+ self.conn.commit()
49
+ return h
50
+
51
+ def head(self) -> str:
52
+ """Current tip hash (GENESIS if the log is empty). Record this
53
+ out-of-band; later pass it to verify_chain(expected_head=...) to detect
54
+ tip-truncation (deletion of the most recent entries)."""
55
+ return self._last_hash()
56
+
57
+ def verify_chain(self, expected_head: str | None = None) -> bool:
58
+ """Verify the hash chain links from genesis to the current tip.
59
+
60
+ SECURITY: an internal walk proves no entry was *altered* or removed from
61
+ the *middle* (that breaks a prev_hash link), but it CANNOT detect tip
62
+ truncation — deleting the most recent entries leaves a self-consistent,
63
+ merely shorter chain. To catch an attacker lopping off the tail (erasing
64
+ evidence of recent erasures), the operator must record the head hash
65
+ out-of-band and pass it as ``expected_head`` — the same pin-it-externally
66
+ discipline the certificate uses for the trusted public key. With no
67
+ entries the head is ``GENESIS``.
68
+ """
69
+ with self.conn.cursor() as cur:
70
+ cur.execute(
71
+ "SELECT entry, prev_hash, entry_hash FROM lethe_audit ORDER BY seq ASC"
72
+ )
73
+ rows = cur.fetchall()
74
+ prev = GENESIS
75
+ for entry, prev_hash, entry_hash in rows:
76
+ if prev_hash != prev:
77
+ return False
78
+ if _entry_hash(prev_hash, entry) != entry_hash:
79
+ return False
80
+ prev = entry_hash
81
+ if expected_head is not None and not hmac.compare_digest(prev, expected_head):
82
+ return False
83
+ return True
lethe/cert_schema.py ADDED
@@ -0,0 +1,82 @@
1
+ """Machine verification of Lethe certificates: JSON-Schema shape check plus
2
+ key-pinned cryptographic verification with structured, agent-branchable reasons."""
3
+
4
+ import base64
5
+ import copy
6
+ import hashlib
7
+ import hmac as hmac_mod
8
+ import json
9
+ from functools import lru_cache
10
+ from importlib import resources
11
+
12
+ import jsonschema
13
+
14
+ from .certificate import canonical_payload_bytes
15
+ from .signing import verify_signature
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def _load_schema() -> dict:
20
+ text = (
21
+ resources.files("lethe").joinpath("schemas/certificate-v1.json").read_text()
22
+ )
23
+ return json.loads(text)
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def _validator() -> jsonschema.Draft202012Validator:
28
+ return jsonschema.Draft202012Validator(_load_schema())
29
+
30
+
31
+ def load_schema() -> dict:
32
+ """Return the certificate v1 JSON Schema.
33
+
34
+ Returns a defensive deep copy: the cached schema dict is shared process-wide,
35
+ so callers must never be handed the mutable original.
36
+ """
37
+ return copy.deepcopy(_load_schema())
38
+
39
+
40
+ def schema_errors(data) -> list[str]:
41
+ return [
42
+ f"{'/'.join(str(p) for p in e.absolute_path) or '<root>'}: {e.message}"
43
+ for e in sorted(
44
+ _validator().iter_errors(data),
45
+ key=lambda e: [str(x) for x in e.absolute_path],
46
+ )
47
+ ]
48
+
49
+
50
+ def verify_certificate_json(data, trusted_public_key: str) -> dict:
51
+ """Verify a certificate received as JSON. The trusted key is REQUIRED:
52
+ an unpinned check only proves self-consistency, never authenticity.
53
+ Reasons an agent can branch on, checked in order:
54
+ SCHEMA_MISMATCH -> KEY_MISMATCH -> PAYLOAD_TAMPERED -> BAD_SIGNATURE."""
55
+ errors = schema_errors(data)
56
+ if errors:
57
+ return {"valid": False, "reasons": ["SCHEMA_MISMATCH"], "detail": errors}
58
+ try:
59
+ embedded = base64.b64decode(data["public_key"], validate=True)
60
+ trusted = base64.b64decode(trusted_public_key, validate=True)
61
+ except Exception:
62
+ return {
63
+ "valid": False, "reasons": ["KEY_MISMATCH"],
64
+ "detail": ["public key is not valid base64"],
65
+ }
66
+ if not hmac_mod.compare_digest(embedded, trusted):
67
+ return {
68
+ "valid": False, "reasons": ["KEY_MISMATCH"],
69
+ "detail": ["certificate's embedded key does not match the trusted key"],
70
+ }
71
+ payload_bytes = canonical_payload_bytes(data["payload"])
72
+ if hashlib.sha256(payload_bytes).hexdigest() != data["payload_hash"]:
73
+ return {
74
+ "valid": False, "reasons": ["PAYLOAD_TAMPERED"],
75
+ "detail": ["payload_hash does not match the payload"],
76
+ }
77
+ if not verify_signature(data["public_key"], payload_bytes, data["signature"]):
78
+ return {
79
+ "valid": False, "reasons": ["BAD_SIGNATURE"],
80
+ "detail": ["Ed25519 signature verification failed"],
81
+ }
82
+ return {"valid": True, "reasons": [], "detail": []}
lethe/certificate.py ADDED
@@ -0,0 +1,126 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+
6
+ from .models import Certificate, LayerResult
7
+ from .signing import Signer, verify_signature
8
+
9
+ CLAIM = (
10
+ "Deleted across the listed retrieval layers and verified absent by re-query "
11
+ "at issue time against the configured endpoint. Not a guarantee of erasure "
12
+ "from backups, model weights, or systems outside Lethe's configured "
13
+ "connectors, nor a guarantee against read replicas, caches, or asynchronous "
14
+ "propagation (e.g. eventually-consistent stores such as Pinecone)."
15
+ )
16
+
17
+ CERT_SCHEMA_VERSION = "lethe.cert/1"
18
+
19
+
20
+ def _canonical_bytes(payload: dict) -> bytes:
21
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
22
+
23
+
24
+ def build_certificate(
25
+ *,
26
+ request_id: str,
27
+ subject_hash: str,
28
+ layers: list[LayerResult],
29
+ issued_at: str,
30
+ version: str,
31
+ signer: Signer,
32
+ ) -> Certificate:
33
+ ordered = sorted(layers, key=lambda l: (l.store, l.namespace))
34
+
35
+ def _is_erasure(l: LayerResult) -> bool:
36
+ # A layer is a genuine, certifiable erasure only when Lethe was handled
37
+ # by a real connector, confirmed the records absent, AND actually removed
38
+ # records this run. deleted_count 0 with verified_absent True means the
39
+ # data was already gone (another subject's forget, or a prior partial
40
+ # run) — Lethe did NOT perform this erasure and must not certify it.
41
+ return l.handled and l.verified_absent and l.deleted_count > 0
42
+
43
+ erasures = [_is_erasure(l) for l in ordered]
44
+
45
+ # all_verified is the load-bearing claim a regulator relies on. It is True
46
+ # ONLY if at least one layer was found AND every layer is a genuine erasure.
47
+ # Empty layers (unknown subject / nothing found) => all([]) would be True;
48
+ # we require a non-empty layer set so "found nothing" can never read as a
49
+ # successful, verified erasure.
50
+ all_verified = bool(ordered) and all(erasures)
51
+
52
+ payload = {
53
+ "schema": CERT_SCHEMA_VERSION,
54
+ "request_id": request_id,
55
+ "subject_hash": subject_hash,
56
+ "issued_at": issued_at,
57
+ "lethe_version": version,
58
+ "claim": CLAIM,
59
+ "all_verified": all_verified,
60
+ # Honest summary of what actually happened, so a zero-deletion or
61
+ # unhandled-store outcome cannot be misread off the layer list.
62
+ "layers_found": len(ordered),
63
+ "records_deleted": sum(l.deleted_count for l in ordered),
64
+ "all_layers_handled": all(l.handled for l in ordered) if ordered else True,
65
+ "layers": [
66
+ {
67
+ "store": l.store,
68
+ "namespace": l.namespace,
69
+ "deleted_count": l.deleted_count,
70
+ "verified_absent": l.verified_absent,
71
+ "requested_count": l.requested_count,
72
+ "handled": l.handled,
73
+ "erased": erased,
74
+ }
75
+ for l, erased in zip(ordered, erasures)
76
+ ],
77
+ }
78
+ data = _canonical_bytes(payload)
79
+ return Certificate(
80
+ payload=payload,
81
+ payload_hash=hashlib.sha256(data).hexdigest(),
82
+ signature=signer.sign(data),
83
+ public_key=signer.public_key_b64(),
84
+ )
85
+
86
+
87
+ def verify_certificate(cert: Certificate, trusted_public_key: str) -> bool:
88
+ """Verify a deletion certificate. Key pinning is MANDATORY.
89
+
90
+ SECURITY: a certificate carries the public key that signed it, so a
91
+ self-verifying check ("does the embedded key validate the embedded
92
+ signature?") only proves internal consistency, NOT authenticity — an
93
+ attacker can mint a fully self-consistent certificate with their own
94
+ keypair. A certificate can therefore only be verified against the
95
+ operator's out-of-band-published key (e.g. from /.well-known), passed as
96
+ ``trusted_public_key``; self-consistency-only checks are not proof and
97
+ are no longer offered. The certificate's embedded key must match the
98
+ trusted key before the signature is even checked.
99
+ """
100
+ try:
101
+ embedded = base64.b64decode(cert.public_key, validate=True)
102
+ trusted = base64.b64decode(trusted_public_key, validate=True)
103
+ except Exception:
104
+ return False
105
+ if not hmac.compare_digest(embedded, trusted):
106
+ return False
107
+
108
+ data = _canonical_bytes(cert.payload)
109
+ if hashlib.sha256(data).hexdigest() != cert.payload_hash:
110
+ return False
111
+ return verify_signature(cert.public_key, data, cert.signature)
112
+
113
+
114
+ def canonical_payload_bytes(payload: dict) -> bytes:
115
+ """Public alias: the exact byte encoding that is hashed and signed."""
116
+ return _canonical_bytes(payload)
117
+
118
+
119
+ def certificate_to_dict(cert: Certificate) -> dict:
120
+ """The wire/JSON envelope — matches schemas/certificate-v1.json."""
121
+ return {
122
+ "payload": cert.payload,
123
+ "payload_hash": cert.payload_hash,
124
+ "signature": cert.signature,
125
+ "public_key": cert.public_key,
126
+ }
lethe/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ import json
2
+
3
+ import click
4
+ import psycopg
5
+
6
+ from .audit import AuditLog
7
+ from .certificate import verify_certificate
8
+ from .connectors.pgvector import PgVectorConnector
9
+ from .core import Lethe
10
+ from .ledger import Ledger
11
+ from .models import Certificate
12
+ from .signing import Signer
13
+
14
+
15
+ @click.group()
16
+ def cli() -> None:
17
+ """Lethe — the forget button for AI."""
18
+
19
+
20
+ @cli.command("keygen")
21
+ @click.option("--out", "out", required=True, help="Path to write the Ed25519 private key.")
22
+ def keygen(out: str) -> None:
23
+ signer = Signer.generate()
24
+ with open(out, "wb") as f:
25
+ f.write(signer.private_bytes())
26
+ click.echo(f"Wrote signing key to {out}")
27
+ click.echo(f"Public key (publish this for verifiers): {signer.public_key_b64()}")
28
+
29
+
30
+ @cli.command("init-db")
31
+ @click.option("--database-url", envvar="DATABASE_URL", required=True)
32
+ def init_db(database_url: str) -> None:
33
+ with psycopg.connect(database_url) as conn:
34
+ Ledger(conn).init_schema()
35
+ AuditLog(conn).init_schema()
36
+ click.echo("Lethe schema initialized.")
37
+
38
+
39
+ @cli.command("forget")
40
+ @click.argument("subject_id")
41
+ @click.option("--database-url", envvar="DATABASE_URL", required=True)
42
+ @click.option("--salt", envvar="LETHE_SALT", required=True)
43
+ @click.option("--key-file", envvar="LETHE_KEY_FILE", required=True)
44
+ def forget(subject_id: str, database_url: str, salt: str, key_file: str) -> None:
45
+ with open(key_file, "rb") as f:
46
+ signer = Signer.from_private_bytes(f.read())
47
+ with psycopg.connect(database_url) as conn:
48
+ lethe = Lethe(
49
+ ledger=Ledger(conn),
50
+ audit=AuditLog(conn),
51
+ signer=signer,
52
+ connectors={"pgvector": PgVectorConnector(conn)},
53
+ salt=salt,
54
+ )
55
+ cert = lethe.forget(subject_id)
56
+ click.echo(
57
+ json.dumps(
58
+ {
59
+ "payload": cert.payload,
60
+ "payload_hash": cert.payload_hash,
61
+ "signature": cert.signature,
62
+ "public_key": cert.public_key,
63
+ },
64
+ indent=2,
65
+ )
66
+ )
67
+
68
+
69
+ @cli.command("verify")
70
+ @click.argument("cert_file")
71
+ @click.option(
72
+ "--public-key",
73
+ "public_key",
74
+ required=True,
75
+ help="The operator's published (trusted) Ed25519 public key, base64. "
76
+ "Required: an unpinned check only proves self-consistency, not authenticity.",
77
+ )
78
+ def verify(cert_file: str, public_key: str) -> None:
79
+ with open(cert_file) as f:
80
+ data = json.load(f)
81
+ cert = Certificate(
82
+ payload=data["payload"],
83
+ payload_hash=data["payload_hash"],
84
+ signature=data["signature"],
85
+ public_key=data["public_key"],
86
+ )
87
+ ok = verify_certificate(cert, trusted_public_key=public_key)
88
+ click.echo("VALID" if ok else "INVALID")
89
+ raise SystemExit(0 if ok else 1)
90
+
91
+
92
+ @cli.command("audit-head")
93
+ @click.option("--database-url", envvar="DATABASE_URL", required=True)
94
+ def audit_head(database_url: str) -> None:
95
+ """Print the current audit-log head hash. Record it out-of-band so you can
96
+ later detect tip-truncation with `verify-audit --expected-head`."""
97
+ with psycopg.connect(database_url) as conn:
98
+ click.echo(AuditLog(conn).head())
99
+
100
+
101
+ @cli.command("verify-audit")
102
+ @click.option("--database-url", envvar="DATABASE_URL", required=True)
103
+ @click.option(
104
+ "--expected-head",
105
+ default=None,
106
+ help="The audit head you recorded out-of-band. Without it, tip-truncation "
107
+ "(deletion of the most recent entries) cannot be detected.",
108
+ )
109
+ def verify_audit(database_url: str, expected_head: str | None) -> None:
110
+ with psycopg.connect(database_url) as conn:
111
+ ok = AuditLog(conn).verify_chain(expected_head=expected_head)
112
+ if expected_head is None:
113
+ click.echo(
114
+ "WARNING: no --expected-head given; internal links checked but "
115
+ "tip-truncation is undetectable.",
116
+ err=True,
117
+ )
118
+ click.echo("VALID" if ok else "INVALID")
119
+ raise SystemExit(0 if ok else 1)
File without changes
@@ -0,0 +1,14 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+
4
+ @runtime_checkable
5
+ class Connector(Protocol):
6
+ name: str
7
+
8
+ def delete(self, namespace: str, record_ids: list[str]) -> int:
9
+ """Delete the given records from `namespace`; return rows deleted."""
10
+ ...
11
+
12
+ def verify(self, namespace: str, record_ids: list[str]) -> bool:
13
+ """Return True only if none of `record_ids` remain in `namespace`."""
14
+ ...
@@ -0,0 +1,33 @@
1
+ import psycopg
2
+ from psycopg import sql
3
+
4
+
5
+ class PgVectorConnector:
6
+ name = "pgvector"
7
+
8
+ def __init__(self, conn: psycopg.Connection, id_column: str = "id"):
9
+ self.conn = conn
10
+ self.id_column = id_column
11
+
12
+ def delete(self, namespace: str, record_ids: list[str]) -> int:
13
+ if not record_ids:
14
+ return 0
15
+ query = sql.SQL("DELETE FROM {tbl} WHERE {col} = ANY(%s)").format(
16
+ tbl=sql.Identifier(namespace), col=sql.Identifier(self.id_column)
17
+ )
18
+ with self.conn.cursor() as cur:
19
+ cur.execute(query, (record_ids,))
20
+ n = cur.rowcount
21
+ self.conn.commit()
22
+ return n
23
+
24
+ def verify(self, namespace: str, record_ids: list[str]) -> bool:
25
+ if not record_ids:
26
+ return True
27
+ query = sql.SQL("SELECT count(*) FROM {tbl} WHERE {col} = ANY(%s)").format(
28
+ tbl=sql.Identifier(namespace), col=sql.Identifier(self.id_column)
29
+ )
30
+ with self.conn.cursor() as cur:
31
+ cur.execute(query, (record_ids,))
32
+ (count,) = cur.fetchone()
33
+ return count == 0
@@ -0,0 +1,59 @@
1
+ """Pinecone connector: delete records by id and verify their absence.
2
+
3
+ Wraps a Pinecone ``Index`` object (duck-typed — Lethe does not depend on the
4
+ pinecone client). Lethe's ``namespace`` maps to a Pinecone namespace.
5
+
6
+ Three Pinecone realities shape this connector:
7
+ * ``index.delete(ids=...)`` returns no count, so to report an accurate
8
+ ``deleted_count`` for the certificate, ``delete()`` first ``fetch``es the
9
+ ids to see how many actually existed, then deletes.
10
+ * Pinecone upserts on id collision, so ids must be unique per record — the
11
+ LetheVectorStore wrapper enforces that up front (see its id_key handling).
12
+ * Pinecone deletes are EVENTUALLY CONSISTENT. ``verify()`` fetches
13
+ immediately after ``delete()``, so ``verified_absent`` reflects what the
14
+ queried endpoint returns at issue time — propagation to every replica is
15
+ not instantaneous. The certificate's claim is scoped to say exactly this;
16
+ do not read a Pinecone ``verified_absent: true`` as a cross-replica proof.
17
+ """
18
+
19
+ # Pinecone caps ids per delete/fetch call; chunk to stay under it.
20
+ _BATCH = 1000
21
+
22
+
23
+ def _chunks(seq, n):
24
+ for i in range(0, len(seq), n):
25
+ yield seq[i : i + n]
26
+
27
+
28
+ def _fetched_ids(resp) -> set:
29
+ # pinecone-client returns a FetchResponse with `.vectors`, or a dict with
30
+ # a "vectors" key, mapping id -> vector. Support both shapes.
31
+ vectors = getattr(resp, "vectors", None)
32
+ if vectors is None and isinstance(resp, dict):
33
+ vectors = resp.get("vectors", {})
34
+ return set((vectors or {}).keys())
35
+
36
+
37
+ class PineconeConnector:
38
+ name = "pinecone"
39
+
40
+ def __init__(self, index):
41
+ self.index = index
42
+
43
+ def delete(self, namespace: str, record_ids: list[str]) -> int:
44
+ if not record_ids:
45
+ return 0
46
+ existing: set = set()
47
+ for chunk in _chunks(record_ids, _BATCH):
48
+ existing |= _fetched_ids(self.index.fetch(ids=list(chunk), namespace=namespace))
49
+ for chunk in _chunks(record_ids, _BATCH):
50
+ self.index.delete(ids=list(chunk), namespace=namespace)
51
+ return len(existing)
52
+
53
+ def verify(self, namespace: str, record_ids: list[str]) -> bool:
54
+ if not record_ids:
55
+ return True
56
+ for chunk in _chunks(record_ids, _BATCH):
57
+ if _fetched_ids(self.index.fetch(ids=list(chunk), namespace=namespace)):
58
+ return False
59
+ return True
lethe/core.py ADDED
@@ -0,0 +1,145 @@
1
+ import uuid
2
+ from collections import defaultdict
3
+ from datetime import datetime, timezone
4
+
5
+ from .audit import AuditLog
6
+ from .certificate import build_certificate
7
+ from .connectors.base import Connector
8
+ from .hashing import hash_subject
9
+ from .ledger import Ledger
10
+ from .models import Certificate, LayerResult, TagRecord
11
+ from .signing import Signer
12
+ from .version import __version__
13
+
14
+
15
+ class Lethe:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ ledger: Ledger,
20
+ audit: AuditLog,
21
+ signer: Signer,
22
+ connectors: dict[str, Connector],
23
+ salt: str,
24
+ ):
25
+ self.ledger = ledger
26
+ self.audit = audit
27
+ self.signer = signer
28
+ self.connectors = connectors
29
+ self.salt = salt
30
+
31
+ def _subject_hash(self, subject_id: str) -> str:
32
+ return hash_subject(subject_id, self.salt)
33
+
34
+ def tag(self, subject_id: str, store: str, namespace: str, record_id: str) -> None:
35
+ self.ledger.record(
36
+ TagRecord(
37
+ subject_hash=self._subject_hash(subject_id),
38
+ store=store,
39
+ namespace=namespace,
40
+ record_id=record_id,
41
+ )
42
+ )
43
+
44
+ def preview(self, subject_id: str) -> dict:
45
+ """Read-only blast radius: what forget() WOULD touch, per layer.
46
+ Feeds the MCP two-step guard; deletes nothing, purges nothing."""
47
+ subject_hash = self._subject_hash(subject_id)
48
+ counts: dict[tuple[str, str], int] = defaultdict(int)
49
+ for row in self.ledger.lookup(subject_hash):
50
+ counts[(row.store, row.namespace)] += 1
51
+ return {
52
+ "subject_hash": subject_hash,
53
+ "layers": [
54
+ {"store": store, "namespace": namespace, "count": n}
55
+ for (store, namespace), n in sorted(counts.items())
56
+ ],
57
+ }
58
+
59
+ def forget(
60
+ self,
61
+ subject_id: str,
62
+ *,
63
+ request_id: str | None = None,
64
+ now: datetime | None = None,
65
+ ) -> Certificate:
66
+ subject_hash = self._subject_hash(subject_id)
67
+ request_id = request_id or str(uuid.uuid4())
68
+ issued_at = (now or datetime.now(timezone.utc)).isoformat()
69
+
70
+ groups: dict[tuple[str, str], list[str]] = defaultdict(list)
71
+ for row in self.ledger.lookup(subject_hash):
72
+ groups[(row.store, row.namespace)].append(row.record_id)
73
+
74
+ # Pre-flight audit event: a mid-loop connector failure must never
75
+ # leave real deletions with no audit trace; the started/completed
76
+ # pair brackets every destructive attempt.
77
+ self.audit.append(
78
+ {
79
+ "event": "forget_started",
80
+ "request_id": request_id,
81
+ "subject_hash": subject_hash,
82
+ "issued_at": issued_at,
83
+ "requested_layers": len(groups),
84
+ }
85
+ )
86
+
87
+ layers: list[LayerResult] = []
88
+ for (store, namespace), ids in groups.items():
89
+ connector = self.connectors.get(store)
90
+ if connector is None:
91
+ # A tagged store with no configured connector must NOT crash the
92
+ # loop mid-flight (that would leave already-deleted layers with no
93
+ # certificate). Record it as an unhandled, unverified layer so the
94
+ # certificate is honest and the ledger is preserved for retry once
95
+ # the connector is configured.
96
+ layers.append(
97
+ LayerResult(
98
+ store,
99
+ namespace,
100
+ deleted_count=0,
101
+ verified_absent=False,
102
+ requested_count=len(ids),
103
+ handled=False,
104
+ )
105
+ )
106
+ continue
107
+ deleted = connector.delete(namespace, ids)
108
+ verified = connector.verify(namespace, ids)
109
+ layers.append(
110
+ LayerResult(
111
+ store,
112
+ namespace,
113
+ deleted_count=deleted,
114
+ verified_absent=verified,
115
+ requested_count=len(ids),
116
+ handled=True,
117
+ )
118
+ )
119
+
120
+ cert = build_certificate(
121
+ request_id=request_id,
122
+ subject_hash=subject_hash,
123
+ layers=layers,
124
+ issued_at=issued_at,
125
+ version=__version__,
126
+ signer=self.signer,
127
+ )
128
+
129
+ self.audit.append(
130
+ {
131
+ "event": "forget",
132
+ "request_id": request_id,
133
+ "subject_hash": subject_hash,
134
+ "issued_at": issued_at,
135
+ "payload_hash": cert.payload_hash,
136
+ "all_verified": cert.payload["all_verified"],
137
+ }
138
+ )
139
+
140
+ # Only purge the provenance map once deletion is fully verified — otherwise
141
+ # we keep the map so the operation can be retried.
142
+ if cert.payload["all_verified"]:
143
+ self.ledger.purge(subject_hash)
144
+
145
+ return cert