stable

forget

GDPR-style right-to-erasure: `vault.forget(subjectId)` crypto-shreds every matching record — live body, history, side-cars — leaving a provable ledger entry.
Subpath@noy-db/hub/forget
FactorywithForget()
Layerwith-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 alongside withForget().
  • A subjects declaration 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 next push(). 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 (since 0.3.0-pre.13, #785): derivedResidueUndecodable when the row's ownership stamp couldn't be decoded (ownership unconfirmed — e.g. an output elevated above tier 0), versus derivedResidueDeclined when 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's onDelete semantics apply: restrict (the default) refuses the whole forget() call with DictKeyInUseError before any shred while references still exist; cascade/nullify propagate after the shred and are reported on lookupReferencesCascaded / lookupReferencesNullified (lookupReferencesResidue names 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:

FieldTypePurpose
subjectsRecord<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.
scopedPurgeboolean (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:

FieldTypeMeaning
subjectstringThe subject id passed in, echoed back.
recordsShreddednumberLive records rewritten to a tombstone.
historyVersionsShreddednumberHistory envelopes tombstoned across all shredded records.
collectionsreadonly string[]Distinct collections that had at least one record shredded.
unmigratedRecordsreadonly 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.
blobsShreddednumberErasable blob attachments crypto-shredded (their own key destroyed).
blobsRetainedSharednumberErasable blobs kept because another record still references the same content.
blobResidueCollectionsreadonly 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.
indexPostingsPurgednumberPersisted lookup-index side-cars deleted for the shredded records.
indexResiduereadonly string[]collection:id:field entries whose index side-car could not be deleted — still leaks the indexed value.
sealedFieldsShreddednumberSealed-field slots (see classified) dropped because they were keyed off the per-record CEK.
sealedCekEnvelopesPurgednumberSealed-record host-delivery envelopes deleted — a record sealed to an at-* host persists its raw CEK there, and forget() destroys it too.
sealedCekResiduereadonly string[]collection:id pairs whose sealed-record host-delivery envelope purge failed.
sealedResiduereadonly string[]collection:id:field sealed slots that were keyed off the shared collection key (legacy) and so were not crypto-shredded by the tombstone.
scopedPurgeResiduereadonly 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).
derivedRecordsErasednumberRecord-grain derived artifacts erased — materialized-view rows, derivation rows, same-id derived copies (since 0.3.0-pre.9).
derivedAggregatesRecomputednumberAggregate-grain rollup targets recomputed without the forgotten contribution (since 0.3.0-pre.9).
derivedResidueFrozenreadonly 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).
derivedResidueUndecodablereadonly 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).
derivedResidueDeclinedreadonly 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).
lookupReferencesCascadednumberReferencing records tombstoned via a lookup field's cascade reference semantics (since 0.3.0-pre.9).
lookupReferencesNullifiednumberReferencing fields cleared via a lookup field's nullify reference semantics (since 0.3.0-pre.9).
lookupReferencesResiduereadonly 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).
ledgerEntryLedgerEntryThe 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() and vault.rebuildSubjectIndex() throw ForgetStrategyNotConfiguredError, no write hooks register, and no collection is forced into perRecordKeys — 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 configured historyStrategy it 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 restrict semantics, forget() throws DictKeyInUseError before shredding anything — retire or re-point the referencing records first, or declare cascade/nullify on 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 / sealedResidue are classified
  • sealed-record — the at-* host delivery envelopes sealedCekEnvelopesPurged / sealedCekResidue refer 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.