forget
| Subpath | @noy-db/hub/forget |
| Factory | withForget() |
| Layer | with-audit |
What it does
Privacy laws such as the GDPR give a person the right to have their data erased on request. The forget service implements that as crypto-shredding: instead of hunting down and deleting every copy of a record (impossible once backups exist), each record body is encrypted under its own per-record content-encryption key (a CEK) — see Core 02 — Enclave — and "erasing" a record means destroying that key's ciphertext so the ciphertext body can never be decrypted again, anywhere it was copied.
You declare which field identifies the data subject on each collection you want covered
({ invoices: 'buyerId' }). From then on, noy-db maintains an encrypted index mapping each
subject to every record that names them. Calling vault.forget(subjectId) looks that index
up and, for every matching record, rewrites the live envelope and all of its history to an
empty tombstone — so the body becomes permanently unreadable while the collection's key and
every other record stay intact — then appends one entry to the change ledger (see
history) proving the erasure happened without retaining the subject's
data.
Prerequisites
historyStrategy: withHistory()—forget()writes its erasure proof into the change ledger, so history must be enabled alongsidewithForget().- A
subjectsdeclaration in the factory — mapping each covered collection to its subject field. - Coverage note: a collection's classified/blob fields count as covered only if declared in the same session's config (detailed under the coverage rules below).
Opt in
Pass forgetStrategy: withForget({ subjects: {...} }) to createNoydb. forget()
also needs the change ledger for its erasure proof, so historyStrategy: withHistory() must
be configured too:
import { createNoydb } from '@noy-db/hub'
import { withForget } from '@noy-db/hub/forget'
import { withHistory } from '@noy-db/hub/history'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(), // any NoydbStore works
user: 'alice',
secret: 'correct horse battery staple',
historyStrategy: withHistory(), // required — forget() writes its proof here
forgetStrategy: withForget({ subjects: { invoices: 'buyerId' } }),
})
const vault = await db.openVault('acme')
Declaring a collection in subjects also forces perRecordKeys: true for it — a shred can
only guarantee erasure of a body keyed off its own CEK, so adopting forget on a collection
opts it into per-record keys automatically.
Usage
Erase a subject
Every put on a declared collection updates the encrypted subject index behind the scenes.
vault.forget(subjectId) finds and shreds everything indexed under that subject:
interface Invoice { id: string; buyerId: string; amount: number }
const invoices = vault.collection<Invoice>('invoices')
await invoices.put('inv-1', { id: 'inv-1', buyerId: 'buyer-123', amount: 100 })
await invoices.put('inv-1', { id: 'inv-1', buyerId: 'buyer-123', amount: 150 }) // 2nd version → history
const result = await vault.forget('buyer-123')
{
"subject": "buyer-123",
"recordsShredded": 1,
"historyVersionsShredded": 1,
"collections": ["invoices"],
"unmigratedRecords": [],
"blobsShredded": 0,
"blobsRetainedShared": 0,
"blobResidueCollections": [],
"indexPostingsPurged": 0,
"indexResidue": [],
"sealedFieldsShredded": 0,
"sealedCekEnvelopesPurged": 0,
"sealedCekResidue": [],
"sealedResidue": [],
"scopedPurgeResidue": [],
"derivedRecordsErased": 0,
"derivedAggregatesRecomputed": 0,
"derivedResidueFrozen": [],
"derivedResidueUndecodable": [],
"derivedResidueDeclined": [],
"lookupReferencesCascaded": 0,
"lookupReferencesNullified": 0,
"lookupReferencesResidue": [],
"ledgerEntry": { "op": "forget", "collection": "", "id": "", "version": 0, "payloadHash": "<sha256 of subject id>", "…": "…" }
}
Once shredded, the record reads back empty rather than throwing:
await invoices.get('inv-1')
// → null
A second forget('buyer-123') call is a no-op: recordsShredded comes back 0 and nothing
throws.
The erasure propagates — sync, derived copies, references
Three fan-outs run beyond the record's own body, history, and side-cars:
- Sync (since
0.3.0-pre.7). On a synced vault, the tombstones enter the sync dirty log, so the shred travels on the nextpush(). Sync treats a forget tombstone as terminal: a peer's live edit can never overwrite it, in either direction, and conflict resolvers are never consulted against one — see sync → erasures are terminal. - Derived copies (since
0.3.0-pre.9). If the vault uses derivations, materialized views, or computed fields,forget()asks the field-feature dependency graph for the forgotten record's derived artifacts and fans out: record-grain artifacts (MV rows, derivation rows, same-id derived copies) are erased (derivedRecordsErased); aggregate-grain rollups are recomputed without the forgotten contribution (derivedAggregatesRecomputed) — or, when the rollup's target sits in a period frozen by periods, the recompute is skipped and audited instead (derivedResidueFrozen), while the subject's own record is still fully shredded. A materialized-view output row that resists erasure is surfaced with audit candor rather than silently skipped, and the reason is split (since0.3.0-pre.13, #785):derivedResidueUndecodablewhen the row's ownership stamp couldn't be decoded (ownership unconfirmed — e.g. an output elevated above tier 0), versusderivedResidueDeclinedwhen the row decoded and matched but the tier gate declined its erasure (ownership confirmed, a live copy deliberately retained). Both reasons are entries in the tier map on tiers. - Lookup references (since
0.3.0-pre.9). If the subject's record is a backing row of a declared lookup field, the field'sonDeletesemantics apply:restrict(the default) refuses the wholeforget()call withDictKeyInUseErrorbefore any shred while references still exist;cascade/nullifypropagate after the shred and are reported onlookupReferencesCascaded/lookupReferencesNullified(lookupReferencesResiduenames any propagation skipped because a reference's compare-key could not be resolved — always empty in the ordinary case, never silent when non-empty).
Scope the purge with scopedPurge
Two of the fan-outs above scan every collection by default — declared in subjects or not:
the sealed-CEK host-delivery envelope purge (sealedCekEnvelopesPurged) and the blob shred
(blobsShredded). That makes an erasure complete even for a sealed value or blob attached to a
collection you never declared as a subject — at the cost of a full scan.
Opt into a narrower scan with scopedPurge: true, a sibling of subjects:
forgetStrategy: withForget({
subjects: { invoices: 'buyerId' },
scopedPurge: true,
})
Under scopedPurge, the sealed-CEK purge is limited to collections that declared
classifiedFields, and the blob scan to collections that declared blobFields. A collection that
declared neither is skipped, never silently — each skip is reported on
ForgetResult.scopedPurgeResidue:
{
"scopedPurgeResidue": [
{ "reason": "skipped-undeclared-sealed-cek", "collection": "audit_log", "count": 3 },
{ "reason": "skipped-undeclared-blob-scan", "collection": "attachments", "count": 12 }
]
}
count aggregates the sealed-CEK envelopes left unpurged (or, for a blob scan, the records whose
blobs went unscanned) for that collection. Leave scopedPurge off — the default — for the
unconditional, always-purged behavior. The declaration signal is session-local: a collection
counts as "declared" only if its classifiedFields / blobFields are declared in the session
running forget(), so run the erasure from a session that has loaded the full schema.
Recover the subject index after a lost write
The subject index is read-modify-write with no compare-and-swap, so two concurrent writers
racing on the same subject can lose one ref (last write wins). vault.rebuildSubjectIndex()
recovers by rescanning every declared collection from the canonical records:
const reindexed = await vault.rebuildSubjectIndex()
// → 2 (2 distinct subjects found across the declared collections)
API reference
withForget(opts)
function withForget(opts: SubjectDeclaration): ForgetStrategy
Builds the active forget strategy from a subject declaration. Pass it as forgetStrategy to
createNoydb — this is the only wiring step.
SubjectDeclaration:
| Field | Type | Purpose |
|---|---|---|
subjects | Record<string, string> | Maps a collection name to the record field (dotted path supported, e.g. 'billing.buyerId') that identifies the data subject. Declaring a collection here forces perRecordKeys: true for it. |
scopedPurge | boolean (optional, default false) | When true, limits the sealed-CEK envelope purge and the blob scan to collections that declared classifiedFields / blobFields; undeclared collections are skipped and reported on ForgetResult.scopedPurgeResidue. Left off, both purges scan every collection unconditionally. See Scope the purge. |
vault.forget(subjectId)
vault.forget(subjectId: string): Promise<ForgetResult>
Looks up every record referencing subjectId in the encrypted subject index, tombstones
each one's live body and full history, purges its index postings / sealed-field / blob
side-cars, removes it from the subject index, and appends one op:'forget' entry to the
ledger (payloadHash is the SHA-256 of the subject id, so the chain proves the subject
existed and was erased without recording the subject itself).
Throws ForgetStrategyNotConfiguredError if forgetStrategy was never set (or was left at
the default disabled sentinel). Throws if historyStrategy was not configured — the ledger
entry is how the erasure is proven.
ForgetResult:
| Field | Type | Meaning |
|---|---|---|
subject | string | The subject id passed in, echoed back. |
recordsShredded | number | Live records rewritten to a tombstone. |
historyVersionsShredded | number | History envelopes tombstoned across all shredded records. |
collections | readonly string[] | Distinct collections that had at least one record shredded. |
unmigratedRecords | readonly string[] | collection:id pairs shredded while their body was still keyed off the shared collection key (not yet migrated to a per-record CEK) — tombstoned, but pre-shred ciphertext that leaked into a backup before migration may still be decryptable. Migrate the record and re-forget to close the gap. |
blobsShredded | number | Erasable blob attachments crypto-shredded (their own key destroyed). |
blobsRetainedShared | number | Erasable blobs kept because another record still references the same content. |
blobResidueCollections | readonly string[] | Collections with blobs that could not be shredded — legacy blobs with no per-blob key, or a session with the blob service not loaded. |
indexPostingsPurged | number | Persisted lookup-index side-cars deleted for the shredded records. |
indexResidue | readonly string[] | collection:id:field entries whose index side-car could not be deleted — still leaks the indexed value. |
sealedFieldsShredded | number | Sealed-field slots (see classified) dropped because they were keyed off the per-record CEK. |
sealedCekEnvelopesPurged | number | Sealed-record host-delivery envelopes deleted — a record sealed to an at-* host persists its raw CEK there, and forget() destroys it too. |
sealedCekResidue | readonly string[] | collection:id pairs whose sealed-record host-delivery envelope purge failed. |
sealedResidue | readonly string[] | collection:id:field sealed slots that were keyed off the shared collection key (legacy) and so were not crypto-shredded by the tombstone. |
scopedPurgeResidue | readonly ScopedPurgeResidueNotice[] | Under scopedPurge, one entry per undeclared collection skipped by the sealed-CEK purge or blob scan: { reason: 'skipped-undeclared-sealed-cek' | 'skipped-undeclared-blob-scan', collection, count }. Empty when scopedPurge is off (since 0.3.0-pre.11). |
derivedRecordsErased | number | Record-grain derived artifacts erased — materialized-view rows, derivation rows, same-id derived copies (since 0.3.0-pre.9). |
derivedAggregatesRecomputed | number | Aggregate-grain rollup targets recomputed without the forgotten contribution (since 0.3.0-pre.9). |
derivedResidueFrozen | readonly string[] | collection:id derived writes skipped because the target period is frozen — the aggregate temporarily retains the forgotten contribution, audited (since 0.3.0-pre.9). |
derivedResidueUndecodable | readonly string[] | collection:id materialized-view output rows that survived erasure because their _materializedFrom ownership stamp could not be decoded under the collection's default DEK (e.g. elevated above tier 0) — ownership unconfirmed. Surfaced, never silently skipped (since 0.3.0-pre.13, #785). |
derivedResidueDeclined | readonly string[] | collection:id MV output rows that did decode and ownership-stamp-match, but whose erasure was declined by the tier-elevation gate — ownership confirmed, a live tier-holder-decryptable copy deliberately retained (since 0.3.0-pre.13, #785). |
lookupReferencesCascaded | number | Referencing records tombstoned via a lookup field's cascade reference semantics (since 0.3.0-pre.9). |
lookupReferencesNullified | number | Referencing fields cleared via a lookup field's nullify reference semantics (since 0.3.0-pre.9). |
lookupReferencesResidue | readonly string[] | Lookup reference edges whose cascade/nullify propagation was skipped because the compare-key could not be resolved even from the live pre-shred backing row — reported, never silent (since 0.3.0-pre.9). |
ledgerEntry | LedgerEntry | The single op:'forget' entry appended for this erasure. |
vault.rebuildSubjectIndex()
vault.rebuildSubjectIndex(): Promise<number>
Drops and rebuilds the encrypted subject index from the canonical records across every
declared collection. Returns the number of distinct subjects re-indexed. Tombstoned
(already-shredded) records contribute nothing — their body is gone. Throws
ForgetStrategyNotConfiguredError under the disabled sentinel.
Other exports
NO_FORGET— the disabled sentinel ({ subjects: {} }). Under it,vault.forget()andvault.rebuildSubjectIndex()throwForgetStrategyNotConfiguredError, no write hooks register, and no collection is forced intoperRecordKeys— non-adopters pay nothing.SUBJECT_INDEX_COLLECTION— the reserved collection name,'_subject_index'.- Types:
SubjectDeclaration,ForgetStrategy,ForgetResult,ScopedPurgeResidueNotice,SubjectRef.
Limits & gotchas
- Requires
withHistory().forget()writes its erasure proof to the ledger; without a configuredhistoryStrategyit throws instead of shredding. - Single-writer subject index. Index maintenance is read-modify-write with no
compare-and-swap — it assumes noy-db's normal single-process write model. Two writers
racing on the same subject can lose a ref;
vault.rebuildSubjectIndex()recovers it from the canonical records. - Legacy (un-migrated) bodies are a completeness gap, not a failure. A record whose body
was never migrated to a per-record CEK is still tombstoned, but its pre-shred ciphertext —
if it reached a backup before migration — stays decryptable under the collection key. It's
reported in
unmigratedRecords, not silently swallowed. - Idempotent. Calling
forget()again for an already-erased subject shreds nothing and does not throw. - A shredded record reads as
null, not as a tamper error —get()on a tombstoned id behaves like the record never existed. - A referenced backing row refuses erasure first. When the subject's record backs a declared
lookup field with the default
restrictsemantics,forget()throwsDictKeyInUseErrorbefore shredding anything — retire or re-point the referencing records first, or declarecascade/nullifyon the lookup field.
See also
- Services overview · Orientation map
- Core 02 — Enclave — per-record CEKs and the crypto-shred mechanism in full
- history — the change ledger
forget()writes its erasure proof to - blobs — blob attachments and how their content keys are shredded
- classified — sealed fields and how
sealedFieldsShredded/sealedResidueare classified - sealed-record — the
at-*host delivery envelopessealedCekEnvelopesPurged/sealedCekResiduerefer to - sync — how the shred propagates to peers, and why a tombstone is terminal
- lookup — reference semantics that gate or propagate an erasure of a backing row
- computed — the dependency graph behind the derived-residue fan-out
- Proven by showcase
130-with-forget.