Redaction and erasure¶
Two halves of one promise. Redaction is what never gets stored; erasure is what leaves once somebody asks. A deletion that reaches the row and not the embedding built from it has kept neither.
On the way in¶
Every write path — write, append, upsert, supersede, log, index — masks the
value before it stores it, using the same shape detector the run's progress stream and the
telemetry exporter use.
await store.write(scope, record) # value: {"who": "ada@example.com"}
held = await store.read(scope, "k")
held.value # {"who": "[redacted]"}
held.redacted # ("who",)
redacted names the paths rather than counting them, so a reader can tell a masked field
from one that was always empty. It reaches inside nested values — "trip.contact.0" —
because the token is never at the top level.
| Redactor | Masks |
|---|---|
PatternRedactor() — the default |
Emails, API-key prefixes, bearer tokens, JWTs, long hex, card numbers |
PatternRedactor(extra_patterns=(r"CASE-\d+",)) |
The above, plus what a deployment knows about |
None |
Nothing. Has to be asked for rather than arrived at |
Masking is by substring, so "filed under CASE-4471" stores as "filed under [redacted]".
A field that vanished entirely reads as a bug rather than as a decision.
Derived artefacts¶
An embedding, a summary, an index entry, a cache key. Each one still says the thing the record said, so each one registers what it came from:
await store.derived(scope, Derivation(
artefact_id="vec-1", source_id="episodic:e", adapter="vectors"
))
Erasure walks the registry rather than assuming that deleting rows was enough. An artefact two scopes derived is never purged for one of them: the other tenant did not ask to be forgotten.
A DerivedIndex is anything that can purge ids it is handed:
class VectorIndex:
name = "vectors"
async def purge(self, artefact_ids: tuple[str, ...]) -> int: ...
It is never told what a scope or a kind is, and purge must be idempotent, because
erasure resumes by asking again.
Erasing¶
receipt = await store.erase(scope)
receipt.counts # {"profile": 3, "episodic": 1, "semantic": 1}
receipt.records # 5 — every version of a superseded key, not one per key
receipt.artefacts # how many derived artefacts went with them
receipt.adapters # the indices this erasure was responsible for
receipt.completed_at
receipt.complete # True
Two phases. Records are tombstoned first and stop being readable at once — read,
profile, belief, episodes, search and history all skip them. Derived artefacts
are purged second. Nothing is deleted until both have happened, which is what makes the
operation resumable.
kinds= narrows it; dry_run=True returns accurate counts and touches nothing. A dry run
is never complete, because it has kept no promise to anybody. Re-running a finished
erasure returns zero counts rather than raising.
When an index cannot be reached¶
try:
await store.erase(scope)
except PartialErasureError as stalled:
stalled.adapter # "vectors"
stalled.receipt.complete # False
stalled.receipt.outstanding # ("vectors",)
The records stay tombstoned and out of reach, so nothing readable survives the failure.
Run erase again once the index is back: it resumes, and the second receipt does not
re-count what the first already removed.
Audit¶
Each erasure publishes one adk.memory.erased event carrying record and artefact counts,
whether it completed, and which adapters are outstanding. Never a value and never a key —
an audit trail that quotes what was erased has undone the erasure.
A dry run publishes nothing. It erased nothing.
Not here¶
Detecting PII by meaning rather than by shape belongs to the guardrails story. Backup and WAL retention is infrastructure, not a store concern: this promises that nothing readable through the kit survives an erasure.