Reference
Compliance
T3 (GDPR/CCPA-enabling). Event-sourcing is structurally strong for subject rights — every fact about a person is already in the log.
@loom/complianceproductizes that: a subject index answers “which records concern this person?” andexportSubjectassembles an access/portability package. This is compliance-enabling tooling, not legal advice, and the kernel supplies no notion of “data subject” (G3 — that is policy).
Naming the subject
The app names the subject with a SubjectResolver — given an event, return the subject it
concerns (usually read off EXECUTION_STARTED input), or undefined:
const subjectOf = (e) =>
e.eventType === "EXECUTION_STARTED" ? e.payload.input?.userId : undefined;
Subject index
SubjectIndex maintains subjectId → executions (and the O(1) reverse) as a fold over the
event stream. defineConfig wires it live:
const app = defineConfig({
storage: sqliteStorage({ dir: ".data" }),
flows: [signupFlow],
security: {
compliance: { subjectOf },
// Compliance refuses to compose unless the principal is protected: `crypto`, or a codec
// that keeps identity out of the log (this one records none).
principalCodec: { toRef: () => undefined, fromRef: () => undefined },
},
});
app.subjectIndex?.executionsOf("user-42"); // ["exec-a", "exec-b"]
It is rebuildable from a cold log (SubjectIndex.fromEvents(events, subjectOf)) — the live
and rebuilt indexes are identical (P2).
Multi-tenant isolation (T3-R2.4)
In a shared runtime serving several tenants (K12), two tenants can legitimately use the
same subject id ("alice" at tenant A ≠ "alice" at tenant B). Merging them would leak
one tenant’s records into another’s access/erasure request. Supply a tenantOf resolver to
partition the index — the tenant is not persisted in events by default (principal chains
aren’t), so read it from input/metadata:
const app = defineConfig({
storage: sqliteStorage({ dir: ".data" }),
flows: [signupFlow],
security: {
compliance: {
subjectOf: (e) => e.payload.input?.userId,
tenantOf: (e) => e.payload.input?.tenant, // partition key
},
principalCodec: { toRef: () => undefined, fromRef: () => undefined }, // as above
},
});
app.subjectIndex?.executionsOf("alice", "acme"); // only acme's alice
await app.exportSubject("alice", { tenant: "acme" }); // never crosses into globex
await app.eraseSubject("alice", "acme"); // erases only acme's alice
Every query, export, and erasure is tenant-scoped; tenants() lists the partitions. Omit
tenantOf and everything falls in one default partition (single-tenant apps are unchanged).
Access & portability (Art. 15 / 20)
app.exportSubject(subjectId) gathers every execution the index attributes to the subject,
folds each to its current state, and (by default) includes the raw log:
const pkg = await app.exportSubject("user-42");
// { subjectId, executions: [{ executionId, workflowId, status, state, events }] }
// each execution may also carry claimChecked, reservedStreams, legacyCheckpoints, unarchived
// and erasureWindow (see "Archived copies" under Erasure)
// Access-only (no raw event log):
await app.exportSubject("user-42", { includeEvents: false });
An execution whose log was deleted by retention is skipped. For a partially-erased
subject (crypto-shredded fields), wire the crypto store in tolerant mode
(onShredded: "tolerate" on the EncryptingEventStore; the strict default throws PayloadShreddedError, K10-R4) so shredded fields export as
{ $shredded } rather than throwing — an honest export of what remains.
Erasure (Art. 17 — right to be forgotten)
app.eraseSubject(subjectId) destroys the subject’s crypto key so its sensitive fields
become unrecoverable (K10.2). Wire the shredder to the crypto plugin:
const app = defineConfig({
storage: { kind: "custom", eventStore: encryptingStore, snapshotStore },
flows: [signupFlow],
security: {
compliance: {
subjectOf,
erase: {
// MUST be idempotent. shredSubject is: a re-run (key already gone) writes only the
// tombstones still missing, so do NOT skip it just because the key is gone — that is
// "done" only once every log also carries PAYLOAD_SHREDDED, and shredSubject checks
// exactly that per execution (ADR 0106).
shred: async (subjectId, executionIds) => {
await shredSubject(keys, rawEventStore, executionIds[0], {
subjectId,
executionIds,
snapshotStore, // raw or encrypting: the tombstone follows the folded tip
});
},
},
},
},
});
const result = await app.eraseSubject("user-42");
// { outcome: "erased" | "parked" | "unknown", erased: [...], pending: [...] }
shredSubject appends its ERASURE_PENDING and PAYLOAD_SHREDDED events to the raw store
itself. An execution that ever acquired a fence (a fenced resume or inbox wake, a remote cancel,
a worker, recovery, and every parked owner that parked: "shred" cancels) refuses an unfenced
append, so on StaleFenceError the shredder takes a fence and retries; an execution that was
never fenced is not given one. It does so only when the log shows no runner (its last answering
event is a terminal or a park), by compare-and-set on the head it classified where the store
supports it. A direct call on a live fenced
execution still fails with StaleFenceError, before the key is destroyed. eraseSubject shreds
only terminal executions. A shredder you write yourself must do the same.
The one hard rule (T3-R4.5): never shred a fold that is still in flight. Erasure is liveness-gated:
- all of the subject’s executions terminal → shred now (
"erased"). - any still live → park the request (a durable
ERASURE_PENDINGfact on a control stream) and complete it automatically when the last live execution terminates ("parked", with the blocking executions named). The park survives a restart — the control stream folds back to the pending set.
This is honest deferral: the request is recorded and named, never silently dropped.
“Live” means “not terminal”, a PARKED execution included. An execution suspended on an
approval, a timer or ctx.receive is not completed/failed/cancelled/continued, so the
coordinator defers on it. A chat agent that parks on receive forever therefore blocks the
coordinated erasure forever (you get parked + pending, not a silent success). Two ways out:
cancel the execution, or opt in to shredding parked owners:
await app.eraseSubject("user-42", undefined, { parked: "shred" });
// { outcome: "erased", ..., parkedShredded: ["exec-1"] }
parked: "shred" leaves no owner stuck. When every non-terminal execution is suspended
it first CANCELS each parked owner (reason "erased", under the runtime’s own system authority,
through the same fenced path a normal cancel takes) and only then shreds, so every owner is
terminal (cancelled) afterwards and parkedShredded names the ones it cancelled. A running
owner still defers (its fold would read the key as it is destroyed), and so does a subject whose
cancel failed or whose owner woke up before the key went (nothing is shredded; owners already
cancelled stay cancelled). A deferred request completes only when every owner is terminal; re-issue
the call to shred once the running owner parks. Because the owners are terminal heads, the
coordinator’s terminal-head purge also deletes their reserved inbox streams, which removes
plaintext inbox payloads written before crypto was enabled (see the migration edge below).
EraseCoordinator needs its cancel option for this (defineConfig wires it); without it the call
throws. An execution STARTED for the subject while an erasure runs is not covered by it: after the key
is destroyed its sealed writes fail loudly (the key store never mints a destroyed subject’s key
again), so quiesce new work for the subject before erasing. The journaled cancel records only
reason: "erased" (no actor), like any cancel; keep the erasure request itself as the audit record.
An owner that was shredded some OTHER way (a direct shredSubject call on a parked owner) can still
be cancelled: cancel needs no sealed field (status, tip and workflow id are plaintext), so it
folds the redacted view of the log and appends EXECUTION_CANCELLED under the fence. Its resume
and loadState stay strict: both throw the typed PayloadShreddedError (and append nothing); the
error’s redacted property carries the events with each sealed field replaced by
{ $shredded: true } for operator tooling. A compacted log whose checkpoint is sealed cannot be
rebuilt that way and keeps the error. Re-running the erasure over an already-shredded subject is
idempotent: liveness is judged on that redacted view.
Claim-checked tool outputs (ADR 0140)
With the opt-in execution.claimCheck, an agent tool’s large output lives in a CONTENT STORE, not the log, so a
key’s destruction does not reach it. security.crypto with the opt-in is refused at boot unless the store
declares sealsContent: true: wrap the store with EncryptingContentStore from @loom/plugin-crypto (ADR 0143), over the SAME KeyStore and subjectOf as the log (a different one is refused at boot). It seals each blob under its owning subject’s key with a subject-scoped address, so the shred makes every blob unreadable; eraseSubject still journals the blobs it covered and deletes the ciphertext (contentDeleted: { deleted, retained: 0, sealed }).
Citation is not ownership: a log can cite a blob it never put, so erasure asks the store to PROVE the subject owns each cited blob and deletes only those. Others are journaled retained as foreign (another subject’s blob, or an absent one) or unprovable (the proving key was already destroyed, e.g. a re-erasure after a direct shredSubject; the ciphertext is unreadable but cannot be claimed as this subject’s). contentDeleted is { deleted, retained, sealed, foreign, unprovable }. Align the compliance subjectOf (the SubjectIndex) with the crypto subjectOf (the same function, e.g. both by user): when they differ, one data subject spans several executions under distinct keys, and erasure tries the proof under every distinct execution of the subject that cites the address (at most 16 per address), deleting a blob if ANY proves it. A blob cited only by executions outside the subject’s index, or by more than 16 of them with the owner past the cap, stays foreign (retained, never deleted), so a misaligned pair is slower and may retain ciphertext, never wrong.
Over a plaintext store eraseSubject collects the envelope refs the subject’s executions cite (logs, archive
segments, snapshots) before the shred, keeps any blob another non-erased execution still cites (a deduplicated
identical output), journals ERASURE_CONTENT_DELETE_INTENDED first, deletes the rest and journals
ERASURE_CONTENT_DELETED { deleted, retained } (result.contentDeleted); it refuses before the shred
(ContentCopiesRemainError) when blobs are cited and the store cannot delete or the event store cannot be
enumerated. app.exportSubject returns the resolved blobs in each execution’s claimChecked.
Archived copies (ADR 0138, ADR 0139)
Compaction and an inbox trim or terminal delete MOVE events into an ArchiveStore, so a copy of the
subject’s data can outlive the log. Over an archive sealed under the event store’s KeyStore
(EncryptingArchiveStore) the shred erases it. Over a plaintext archive eraseSubject deletes the
subject’s compaction segments and the reserved-stream segments (inbox, topic, quarantine, late runs) of
each erased chain, journals ERASURE_ARCHIVE_DELETED (intent first, ADR 0139 D17) and returns what it
deleted as result.archivedCopiesDeleted ({ executions, reservedStreams }). It also redacts the
subject’s identity where it is the SENDER of an archived envelope in OTHER chains
(sender: { erased: true }, through one atomic ArchiveStore.replaceSegments per key) and returns
result.senderRedactions ([{ streamId, envelopes }]). A live, not yet archived envelope of a plaintext
deployment keeps its sender until it is trimmed.
Erasure fails closed, BEFORE the key is destroyed (nothing is shredded): ArchivedCopiesRemainError
when a plaintext archive holds the subject’s copies and has no deleteExecution, and
SenderRedactionUnsupportedError when it holds the subject as a sender and has no replaceSegments
(ArchiveStore SPI 1.1.0). The in-memory, SQLite and Postgres archives can do both.
app.recoverArchiveRedactions() restores a redaction that an interrupted earlier build left staged (it
also runs at boot).
app.exportSubject reports the same archive: each execution can carry reservedStreams (the envelopes
a trim or delete moved to the archive), legacyCheckpoints (checkpoints of a log compacted before the
archive existed) and unarchived (every range of the original log the export could not return, [] for a
complete log).
A package’s own reserved stream
A package built on loom can keep its own log in a stream under the reserved __loom_ prefix (loom-ui’s
lineage writes __loom_ui_lineage__:<experienceId>). If that log holds personal data, map the stream to
its owner in your subjectOf (it is called with the stream id whenever a field of the stream is sealed
or opened) and register the package’s sensitive event paths with the encrypting store. An
EncryptingContentStore resolves the package’s blobs through the same subjectOf, from the
ContentAccess the package passes. Destroying the owner’s key then makes the stream’s sealed fields and
its blobs unreadable: a read of the stream throws KeyUnavailableError, and a blob read
PayloadShreddedError (or KeyUnavailableError from a KeyStore that does not record destruction).
How to destroy the key:
- The owner also has executions.
app.eraseSubjectreaches the stream through the shared key, as doesshredSubject(keys, raw, id, { subjectId, executionIds })with EVERY one of those executions, so each gets its tombstone. A reserved stream is never tombstoned, so it is not in that list. - The owner has no executions.
app.eraseSubjectreportsoutcome: "unknown"and destroys nothing, because the subject index has no execution for it. CallshredSubject(keys, raw, id, { subjectId, executionIds: [] })directly. Use[]only in this case: a key destroyed with[]leaves any execution sealed under it without a tombstone, and a later shred can no longer write one.
What no key reaches: fields the package did not register, the stream id itself, and anything written before crypto was composed or before the paths were registered.
A message still in flight in an inbox (ADR 0110)
A durable inbox (kernel.features.inbox) holds a message in a reserved stream
(__loom_inbox__:<chainId>, and with per-topic streams one …#<hash> stream per topic plus a
…#reg registry, ADR 0110 D10b) until its owner consumes it, so an erasure must reach the streams
as well as the owner’s log. It does, twice (every stream of the chain, the registry last, on both halves):
- Cryptographically. With
security.cryptocomposed,defineConfigregisters the inbox payload path, and the encrypting store seals a stream’s payload under the OWNER’s subject (the chain id is the generation-0 execution id, so yoursubjectOfalready knows it).shredSubjecttherefore makes an unconsumed payload unreadable; a consumer that reaches it afterwards gets the{ $shredded: true }marker (journaled in the owner’s log like any delivery, so a replay of that log is identical), never the raw value. Inbox reads never throwPayloadShreddedError: a sender’s backlog scan and the trim must keep working. Only a DESTROYED key reads as the marker: ciphertext that an existing key cannot open (corruption, a driftedsubjectOf) throwsPayloadUndecryptableError, so a defect cannot pass for an erasure. Asubscribehandler on the stream gets the marker too. Once the key is destroyed asendis refused (a destroyed key is never recreated; aKeyStoremust not re-mint one). A sender cannot forge the marker:sendrefuses a payload that is exactly{ $shredded: true }.defineConfigalso registersMESSAGE_CONSUMED.payload, so the consumed copy in the owner’s log is sealed (new writes only; an older plaintext consumption still reads and replays). The marker is stored verbatim ONLY there (MESSAGE_CONSUMED.payload, an erased message’s consumption); the same bytes on any other sensitive path (a step output, say) are data, sealed like any value. If the owner’s workflow id cannot be resolved for a chain (asubjectOfkeyed on it) the send FAILS and can be retried; nothing is ever sealed under a guessed subject, and the crypto layer refuses""as a sealing subject. - Physically. When the erasure runs (every execution of the subject terminal), the
coordinator deletes the inbox stream of each execution that is the HEAD of its chain (terminal
and not
continued), read BEFORE the key is destroyed. The runtime already deletes a finished chain’s stream; this reaches the orphan a crash between a sender’s append and its re-read leaves, which the runtime cannot map back to an owner for a fork’s chain. A store withoutdeleteExecutionskips this half; the key still erases the payload.
Migration edge: plaintext written before crypto was enabled survives a shred. A decrypting
read passes an unsealed payload through unchanged, so a message appended (or a consumption
journaled) before security.crypto was composed is plaintext, and destroying the key does not
touch it. Only deleting the stream erases it, which the coordinator does for a TERMINAL chain
head. Remedy: before relying on the key for such a chain, cancel the owner and run the erasure
(its stream is then deleted), or delete the stream yourself; there is no re-seal API, and a
parked owner’s stream is never deleted by the default erasure (it still reads it); parked: "shred" cancels the owner first, so its stream is then deleted.
A node that cannot open a sealed stream (mixed fleets, ADR 0130 residuals D7)
With security.crypto, the encrypting store stamps metadata.seal ({ v, cipher, covers }: the
exact EVENT_TYPE.path entries sealed in THAT envelope) in the same append that seals a field. The
kernel checks the marker before anything is folded, replayed, handed to a flow or authorised
against. A node that cannot open it (a raw store, a crypto node whose sensitive map is narrower,
an older config) or that lacks the key (a different or empty KeyStore) refuses the whole stream:
nothing is appended, the execution is never failed, and a node that CAN open it completes it.
- The refusal looks like an ordinary denial. The caller gets an
ExecutionDeniedErrorwith the unknown-owner reason (UNKNOWN_OWNER_REASON), indistinguishable from the denial of an id that does not exist, andonExecutionDeniedfires with that generic reason. NoEXECUTION_DENIEDfact is written (an incapable node must not write into a log it cannot read). The true cause (marker-unopenable/key-unavailable, sequence, covered paths) goes to operators only:onStorageErrorand the log, the first refusal and then every 16th, andsealedDetailOf(err)from@loom/workflow-runtime/operator. - A sealed principal is refused before the policy is consulted, whatever
unknownOwnersays (a lenient policy can no longer admit a stranger to a stream this node cannot read). - Key gone is two different facts. A real
shredSubjectleaves aPAYLOAD_SHREDDEDtombstone: reads keep raisingPayloadShreddedError. No tombstone means this node’sKeyStorelacks the key: the store raisesKeyUnavailableError, which the kernel turns into the refusal above. Betweenkeys.destroyand a failed tombstone write (ShredIncompleteError) a read reports key-unavailable until the retry completes. That window is recognised only on durable evidence that THIS erasure began:shredSubjectappends anERASURE_PENDING(an existing, ignorable, pure-audit kind) naming the subject to each affected execution’s log BEFORE it destroys the key (intent, then destroy, then tombstone).eraseSubjectandexportSubjectcomplete in the window only when the log holds that intent; a node whose key store never had the key (a wrong key, a partitioned key store) has none, soeraseSubject/exportSubjectrethrowKeyUnavailableErrorandshredSubjectrefuses with it, writing and destroying nothing, whenever sealed data is present and the key is absent. A window left by a pre-intent version has no intent and must be completed by hand. The sealed snapshot read follows the same rule.EraseCoordinatoralso folds each execution’s latest checkpoint (snapshotStore), and a sealing store (EncryptingSnapshotStore, notablysealState: true) cannot open it once the key is gone. In the window the coordinator reads it through the store’sloadLatestSealed(SnapshotStore SPI 1.1.0, asshredSubjectdoes) under the same single gate (isErasureWindow: this subject’s erase intent in the log AND the destruction proof). A whole-state sealed checkpoint has no readable status, so the coordinator does not guess it from the intent: an uncompacted log is folded whole by its plaintext event types (acontinued,suspendedor re-opened execution reads as it is, the same as with path sealing); a log compacted past its prefix is judged by the ruleshredSubjectuses (isQuiescent), and its inbox streams are purged only when the tail shows a non-continuedterminal event (otherwise they are left, and the destroyed key already makes their payloads unreadable). A key that is unavailable but not proved destroyed, or with no intent, still throwsKeyUnavailableError. Nothing re-drives a crashed erasure by itself: in the window every execution of the subject is already terminal, so no further terminal event arrives. CalleraseSubjectagain (an operator or app retry; after a restart too, since the intent is in the log); that retry runs the liveness gate,finishedChains, the shred, the inbox-stream purge and the park bookkeeping. Known residual: a log compacted through its tip whose checkpoint is sealed, with no tail evidence, is read as terminal on the erase intent alone (shredSubject’s own rule); a parked owner in exactly that shape would be shredded and left suspended. Its inbox streams are still kept. Intent is not destruction (round 5). A dangling intent (a crash beforekeys.destroy, or intent N of M failed) leaves the key ALIVE on the node that wrote it, and a retry on a wrong-key node would see the intent. So the retry also needs the KEY STORE’s proof: the optionalKeyStore.wasDestroyed(subjectId), true only if THIS store destroyed the key.shredSubjectthrowsKeyUnavailableError(writing nothing) when the key is absent, an intent is present andwasDestroyedis missing or false;EraseCoordinator({ destroyed }),exportSubject({ destroyed })andcompliance.erase.destroyedtake the same proof ((s) => keys.wasDestroyed(s)) and refuse without it.defineConfigwires that proof by default from the key store behindsecurity.crypto; an explicitcompliance.erase.destroyedoverrides it, and it stays a PROOF function (returningtruefor a key not destroyed in the authoritative store produces a false erasure record). The operator’s override, for a key store that cannot answer, is the per-callforce: "key-destroyed-elsewhere"onshredSubject(...),app.eraseSubject(..., { force })andapp.exportSubject(..., { force }): an assertion that the key was destroyed in the authoritative store, and a wrong one writes a false tombstone. Third-party key stores (KMS, vault, HSM) should implementwasDestroyedand persist the destroy tombstone. - Unmarked streams behave exactly as before. A stream written without a marker (before 1.25.0, or by a third-party sealer) makes no claim and is never refused for lacking one; the inbox shape guard (ADR 0130 D2-D4) still covers inbox payloads. Pre-1.25.0 nodes ignore the marker and keep today’s exposure: upgrade every node, then rely on it.
- Caveats, not residuals: with no authorizer, or
unknownOwner: "allow", a missing id is “not found” rather than denied; a custom authorizer should returnUNKNOWN_OWNER_REASONfor an unknown owner to be indistinguishable.
Snapshots: use sealState. A snapshot’s variables._waitAnswers holds an answered payload under a
dynamic key (the waitId), which a static sensitive path cannot name, so a snapshot taken while an
answered wait is in the fold holds that payload verbatim under a path-list EncryptingSnapshotStore.
Build the snapshot store with new EncryptingSnapshotStore(inner, keys, { sealState: true, events: rawEventStore }) (events is the RAW event store beside it and is optional: with it a real shred keeps PayloadShreddedError for a caller outside the kernel; without it a key-gone load reads as key-unavailable unless the shred is folded into the snapshot, which the runtime turns into the generic denial and whose own log probe still surfaces a real shred as the erasure error; a production store built WITHOUT events therefore reports a shredded subject’s pre-shred snapshot as KeyUnavailableError to a DIRECT (non-kernel) caller, while the kernel and the content GC resolve it through the log tombstone: pass events for the exact error) (the SAME
KeyStore as the event store; mutually exclusive with sensitive): the whole folded state is sealed as
one envelope (only id, executionId, workflowId, sequence, createdAt stay in the clear), and a
shred makes the snapshot unreadable (PayloadShreddedError) along with the events. loom doctor
reports snapshot-plaintext-under-encrypting-events (error for a plaintext or different-keys snapshot
store, warning for static paths) and names security.crypto.allowPlaintextCheckpoints as the explicit
opt-out (ADR 0130 D8). The sealed state is bound to its
execution id and sequence, so a row copied elsewhere or rolled back fails with SnapshotUndecryptableError;
legacyPlaintext: "refuse" forbids unsealed rows once none remain (the doctor checks wiring only and
cannot count plaintext snapshots still at rest). Pass the EncryptingSnapshotStore itself, not its inner
store, to sweepUnpinnedContent (an erasing pass over the raw sealed rows throws SealedSnapshotScanError). Snapshots already written in plaintext are not rewritten: they stay readable and the next
save seals the state.
The kernel refuses a plaintext checkpoint (ADR 0130 D8). When the event store seals payloads
(sealingCapability() reports covers), new WorkflowRuntime / defineConfig throws
PlaintextCheckpointError (reason: "not-sealed" or "keys-not-shared") unless the snapshot store reports
sealsWholeState AND shares the event store’s KeyStore: a plaintext store, a static-sensitive-path
EncryptingSnapshotStore and a sealState store under other keys are all refused, and the message names both
ways out. The rule is re-checked at every save (a store can start sealing after construction): the
breach reaches onStorageError, nothing is written and the run is unaffected, because a checkpoint is an
optimisation and a resume folds the log. LogLifecycle applies it to the retention ARCHIVE snapshot store
too (unsafe-archive). With storage: { kind: "custom", eventStore } and NO snapshotStore, the in-memory
default is built with sealState on the event store’s KeyStore instead of being refused. The explicit
opt-out is security.crypto.allowPlaintextCheckpoints: true (also exposed as
app.security.crypto.allowPlaintextCheckpoints; loom doctor downgrades its findings to advisories): use it
for a snapshot store on an encrypted volume, or to keep booting while you migrate. Plaintext snapshots already
written are not rewritten or deleted.
Three limits of the sealed default, stated plainly. (1) The default keys snapshots by executionId; if your
EncryptingEventStore maps executions to subjects with a different subjectOf (the field is private and is
not visible to defineConfig), build the snapshot store yourself with the SAME subjectOf, or a shred of
the subject will not erase the checkpoint. (2) The in-memory default is node-local and unbounded, so a
durable event store without a durable snapshotStore loses checkpoints on restart (and replays from the
log); defineConfig warns through observability.onWarn when it chooses that default for a store it cannot
see to be in-memory. (3) The kernel judges the snapshot store structurally (sealsWholeState === true and
the identical keys object). A plaintext decorator that inherits BOTH properties from a sealing store (a tee
or cache layer built with Object.create(sealing)) would pass; the guard is against misconfiguration, not a
hostile host object.
The sender recorded on a message (tenant, actor, onBehalfOf) is the SENDER’s identity, not
the owner’s. On a sealing deployment it is sealed by default under an opaque handle derived from
KeyStore material, so eraseSubject also erases the subject AS A SENDER (an erased sender reads
{ erased: true }); senderSubjectOf: false on the EncryptingEventStore opts out (defineConfig
warns), and the sender then sits in the clear unless you declare INBOX_MESSAGE / MESSAGE_CONSUMED
sender.* paths sensitive yourself (ADR 0139 D12). An erased sender stays blocked
(SenderErasedError on send) until app.reinstateSender(actor, tenant?, { by }) journals a
reinstatement; the sender’s next message then gets a new handle and the old messages stay erased.
Erasure does not reach a derived read model
A shred erases the log. It says nothing about anything built from the log, and a
read model is exactly that: RM-2 makes it “a cache of the log with a
watermark, never an authority”. eraseSubject knows the event store and the key store;
it does not know a read model exists, and the only removal verb on ReadModelStore is
clear(modelId) — the whole model, never a row.
So after an erasure, every derived row still holds whatever the extractor copied out before the key died — and those rows are what a console query reads. Invalidating them is a separate, explicit step.
Purge the subject’s rows (preferred)
await readModels.purge("actions-by-operator", { operator: "user-42" });
purge deletes by indexed-field equality — the same semantics query uses, so the rows
it removes are exactly the rows the equivalent query returns — and returns the count
(ADR 0093). Work is proportional to the erased
subject, and it never reads the log, so none of the rebuild hazards below apply.
Three things it deliberately refuses, because an erasure that quietly does nothing is
worse than one that fails: an empty filter (neither “every row” — that is clear —
nor “no rows”), a filter naming an undeclared field (it could never match, so the
report would be a successful erasure of zero rows), and a backend without the method
(PurgeUnsupportedError, naming the fallback). Watermarks are untouched, which is what
makes the deletion stick: the next catch-up has nothing left to re-read.
Folded read-model state (ADR 0132)
A folded read model keeps one state per execution, folded from the log, so it can copy
plaintext out of the log (the live event and every catch-up through EncryptingEventStore are decrypted).
What the model does about it, and what stays yours (ADR 0132 D11, D12, D15):
- Declare the sensitive map. Give
ReadModelService({ sensitive })the union ofsensitivePathsFromSteps(flow.steps)andsensitiveMapFromCovers(enc.sealingCapability().covers), computed afterdefineConfig(it also registers the framework-sealed paths: principal identity,recordedBy, inbox payloads; see data classification). Handlers then receive a copy of the event with those paths (and any cipher envelope) replaced by"[redacted]", and a state holding an envelope or a value lifted from a sensitive path is refused withSensitiveStateError(it names the model, execution, event and state path, never the value). There is no off switch. Prefer decrypted sources for catch-up and rebuild: on an at-rest source only masking applies. - What the guard cannot catch: (a) data in a field nobody classified; (b) a transformed copy (hash, truncation, case folding, concatenation, substring); (c) a copy from an earlier event or another execution, or one under the length thresholds (6 characters, 6 digits); (d) data a handler gets from outside the event (handlers are pure by contract, not sandboxed); (e) free text in undeclared paths; (f) on an at-rest source there is nothing to compare a copy against; (g) on the live feed with no declared map, a sealed field whose seal marker is absent reaches the handler as delivered. State should hold identifiers, enums, counters and timestamps.
- Erase what slips through with
purgeFolded(model, { scope })(or{ executionId }/{ key }) aftereraseSubject. It writes a tombstone: the state becomesNULLanderasedis set, but the watermark is kept. Live and catch-up never resurrect it: later events advance the sequence ("applied-erased") and leave the state empty. Be honest in your records: a tombstone still carries the subject’sexecutionIdandscope. Those are identifiers, not values; if an identifier is itself personal data in your reading of the law, that is a decision for your DPO (ADR 0132 D12). - The cache is never served. The per-node state cache feeds only the node’s own write path and never
answers
getMany,getManyByKeyorrefresh. It is dropped on purge on that node; other nodes may hold the last state they wrote in memory until eviction, restart or the next event for that execution, and an erased marker there is re-checked against the store before use. - Rebuild un-tombstones.
rebuildclears and re-folds, so erased state returns unless the rebuild is erasure-safe: the log must be shredded, the source must tolerate shredded payloads (onShredded: "tolerate"), and handlers must skip{ $shredded: true }. A rebuild through a strict source fails part-way and leaves the model cleared (tombstones and watermarks gone): re-run it through a tolerant source, never a catch-up from a non-shredded plaintext source. - Error reports are safe. The folded engine’s
onErrorreceives the event withpayloadandmetadataremoved, whatever the error kind. A row model’sonErrorstill receives the full event, so do not log it unfiltered.
clear + rebuild (the fallback)
Correct, always available, and O(log) — measured at 68× the cost of a purge for one
subject of 50 in a 20k-event corpus (plugins/read-model/src/purge.bench.ts). Rebuild’s
cost does not depend on how much of the model the subject owned, so budget it against the
erasure SLA. Two obligations it carries that a purge does not
(ADR 0092 D6 and its addendum):
- Rebuild through a tolerant source. The rebuild is a read, and the authoritative
read of a shredded subject throws
PayloadShreddedErrorby design (K10-R4) — so a rebuild through the store the fold uses cannot run at all. It needs anEncryptingEventStoreconfiguredonShredded: "tolerate", the same off-log postureexportSubjecttakes. - Decide what a shredded field means to the extractor. A tolerant read substitutes
{ $shredded: true }for each erased field. An extractor that indexes it verbatim rebuilds the placeholder into the cache: not a leak — the value is genuinely gone — but every erased subject collapses onto one bucket in a field the app declared as an id. UseisShreddedand skip the row, or map it to an explicit tombstone.
plugins/read-model/src/erasure.test.ts pins all of it, including the rebuild that
throws and the purge that works through the same strict source.
Triage & scope
@loom/compliance is a 1P authoring/application-tier package above the kernel — subject
rights are policy, not a kernel or vendor concern. It depends only on the kernel read path
(EventStore.read/append, foldEvents). The shred mechanism is injected, so the package
stays decoupled from any specific crypto plugin.
Not in scope (yet):
- RoPA / breach-scoping (T3-R6) — deferred on manifest-sensitivity (R1.3).
loom compliance report (a cold-storage subject inventory; the event store must support
listExecutions) and the tenant-scoped index (tenantOf, above) have shipped.
Source: docs/compliance.md in the loom repository.