Tombstones (GDPR Erasure)
j17 provides built-in tombstone support for GDPR Article 17 ("Right to Erasure", also known as the "Right to be Forgotten") and similar data deletion requirements. Tombstoning replaces event payloads with tombstone markers while preserving stream structure, hash chain integrity, and an audit trail.
Why tombstones
Event sourcing and GDPR sit in tension: event stores are append-only, but data subjects can request erasure. Deleting events would break hash chains and lose the audit history that regulators expect.
Tombstones resolve this by replacing event payloads in place. The stream keeps its structure and length, the hash chain is recomputed, and the original content hash is preserved so you can prove an event existed without retaining its contents.
How it works
- Request a tombstone for an aggregate (e.g.,
user:alice). - Grace period (minimum 72 hours) allows cancellation.
- Execution replaces all events in the stream with
_was_tombstonedmarkers. - Direct cascade optionally tombstones streams that directly reference the subject at execution time (e.g., direct messages authored by Alice).
- Audit record captures what was deleted, when, by whom, and the legal basis.
After tombstoning, the stream still exists with the same number of events, but every event's payload is replaced with a tombstone marker containing the original content hash (for auditability) and no PII.
Spec configuration
Configure direct tombstone cascade behavior per aggregate type using onTombstone in your spec:
{
"aggregate_types": {
"user": {
"events": { "..." : "..." }
},
"message": {
"events": { "..." : "..." },
"onTombstone": {
"actor": "cascade",
"target": "preserve"
}
},
"order": {
"events": { "..." : "..." },
"onTombstone": {
"actor": "preserve",
"target": "preserve"
}
}
}
}
Cascade rules
Each role (actor, target) can be set to:
| Value | Behavior |
|---|---|
"cascade" |
Tombstone the stream if the deleted entity appears in this role |
"preserve" |
Leave the stream untouched |
In the example above, when user:alice is tombstoned:
message:m1where Alice is the actor (sender) -- tombstoned (actor: "cascade")message:m2where Alice is the target (recipient) -- preserved (target: "preserve")order:o1where Alice is the actor (buyer) -- preserved (actor: "preserve")
Aggregate types without an onTombstone configuration are never scanned during cascade discovery.
How direct discovery works
When a tombstone executes, j17 scans all aggregate types that have at least one "cascade" role in their onTombstone config. For each stream of those types, it reads all events and checks whether the tombstoned entity appears as metadata.actor or metadata.target. If it does, the cascade rule for that role determines whether the stream is tombstoned or preserved.
This is a one-hop, point-in-time operation: j17 follows actor/target relationships that directly name the tombstoned entity when execution is planned. It does not recursively follow references from one cascaded stream to another.
Cascade is stream-granular, not event-granular. If one event in a matching stream directly names the subject in a cascaded role, the entire aggregate stream is tombstoned. Model independently erasable material as its own aggregate: a comment:* stream authored by Alice can be safely cascaded by itself, while configuring post.actor to cascade would erase Bob's whole post:* stream if Alice authored even one comment event inside it. Shared or container aggregates should normally use "preserve".
Tombstone lifecycle
create ──→ pending ──→ executing ──→ completed
│ │
│ └──→ failed ──→ executing
└──→ cancelled
| Status | Meaning |
|---|---|
pending |
Created, within grace period or awaiting manual execution |
executing |
Tombstone execution in progress (streams being rewritten) |
completed |
All streams rewritten, caches cleared, scheduled work cancelled |
cancelled |
Tombstone cancelled during grace period |
failed |
Execution stopped safely; the persisted plan can be resumed |
Grace period
Every tombstone has a minimum grace period of 72 hours (enforced server-side). You can request a longer grace period with the grace_period_days parameter. If you pass a value that results in less than 72 hours, the minimum is applied.
During the grace period, the tombstone can be cancelled. After the grace period elapses, the tombstone can be executed.
To execute before the grace period elapses, pass force=true to the execute endpoint. This is logged as a warning in the server logs.
force accepts only true or false. Any other value returns 400 rather than being read as "don't force" — so a misspelled flag tells you it was misspelled, instead of failing later with a grace-period error you did not expect.
Tombstone event format
After tombstoning, each original event in the stream is replaced with:
{
"key": "user:alice",
"type": "_was_tombstoned",
"data": {
"original_hash": "7f3a2b9c8d1e...",
"chain_hash": "a4b2c1d3e5f6...",
"original_type": "was_created",
"tombstone_id": "550e8400-...",
"tombstoned_at": "2026-03-08T14:30:00Z"
},
"metadata": {
"actor": {"type": "system", "id": "system"},
"timestamp": 1741444200
}
}
| Field | Description |
|---|---|
original_hash |
SHA-256 of the original event JSON (proves an event existed without retaining its contents) |
chain_hash |
The event's hash chain position before tombstoning (allows reconstruction of the pre-tombstone Merkle root) |
original_type |
The event type that was replaced (e.g., was_created, had_profile_updated) |
tombstone_id |
Links back to the tombstone record for audit purposes |
tombstoned_at |
ISO 8601 timestamp of when the tombstone was executed |
The hash chain is recomputed after rewriting. The chain_hash field preserves the pre-tombstone hash so the original Merkle root can be verified against external anchors.
Execution details
When a tombstone executes, the following steps occur in order:
Persist the execution plan -- all aggregate types with
onTombstonecascade rules are scanned. Directly referencing streams are classified ascascadeorpreserve, and the complete plan and one fixed execution timestamp are stored before any stream changes.Snapshot root anchor -- after the plan is fixed, a Merkle root is computed and stored as
pre_tombstone_root. This proves the state immediately before erasure; writes appended after planning are included in the root but preserved by the prefix rewrite.Rewrite streams -- the primary stream and all cascade-marked directly referencing streams are rewritten. Planned events are replaced with
_was_tombstonedmarkers and the hash chain is recomputed. Events appended after the point-in-time plan are preserved. Progress is recorded after every stream, so a retry or restart resumes the same plan instead of rediscovering an incomplete one.Clear caches -- cached aggregates for all tombstoned streams are deleted.
Cancel scheduled work -- any pending scheduled events targeting tombstoned streams are cancelled.
Mark completed -- the tombstone record is updated with
completedstatus,affected_streamssummary, andexecuted_attimestamp.
If execution fails after all retries, the tombstone moves to failed, retains its plan and progress, and can be reattempted. Creation, cancellation, execution claims, and completion are coordinated through authoritative event streams with optimistic concurrency; the dashboard database is only a read projection.
Audit trail
Each completed tombstone records:
| Field | Description |
|---|---|
pre_tombstone_root |
Merkle root computed immediately before erasure (cryptographic proof of prior state) |
affected_streams |
List of streams that were rewritten, with event counts and actions |
executed_at |
When the tombstone was executed |
legal_basis |
The legal justification provided at creation time |
request_id |
External request tracking ID |
requested_by |
Who requested the erasure |
The affected_streams field contains entries like:
[
{"key": "user:alice", "event_count": 12, "action": "tombstoned"},
{"key": "message:m1", "event_count": 3, "action": "tombstoned"},
{"key": "message:m2", "event_count": 5, "action": "tombstoned"}
]
Writes after tombstoning
j17 does not block writes to a tombstoned aggregate key. After tombstoning user:alice, your application can still write new events to user:alice.
This is deliberate:
- j17 stores opaque event payloads. The platform does not know whether your events contain PII. Whether a post-tombstone write reintroduces personal data is a concern for your application layer, not the event store.
- The audit trail is preserved. The tombstone record, pre-tombstone Merkle root, and original content hashes remain regardless of subsequent writes. New events append after the tombstone markers.
- Your application controls the boundary. If you need to prevent writes to tombstoned aggregates, check tombstone status in your application before submitting events.
If re-creating a tombstoned entity reintroduces PII (e.g., because your system still maps the aggregate ID to a real person), that is a data controller responsibility under GDPR, not a storage platform concern.
Best practices
Record the legal basis. Always provide legal_basis and request_id when creating tombstones. These fields are stored in the audit trail and demonstrate compliance to regulators.
Use meaningful requested_by values. Include the operator or system that initiated the request so the audit trail is traceable.
Respect the grace period. The 72-hour minimum exists so accidental tombstone requests can be caught and cancelled. Avoid routine use of force=true.
Design your spec's onTombstone rules carefully. Think through which aggregate types should cascade and which should be preserved. A direct message sent by a user contains PII and should cascade; an order placed by a user might need to be retained for financial records. The right answer depends on your domain and legal requirements.
Tombstoning erases the user, not their footprint. When a user is tombstoned, events they authored on other aggregates (e.g., a was_commented_upon event on an article) remain intact -- only the user's own aggregate stream is erased (plus any cascade-configured streams). The actor reference in those events will point to a tombstoned aggregate, so your application should handle missing actors gracefully (e.g., display "Deleted User"). This is the standard pattern used by GitHub, Reddit, and similar platforms.
Aggregate-level, not event-level. Tombstones erase entire streams, not individual events. If you need to erase specific content (e.g., a single comment's text) without erasing the whole aggregate, store that content in a mutable store outside the event stream and reference it by ID. Then you can delete the content without touching the event history.
Check tombstone status before re-creating entities. If your application allows re-registration with the same aggregate ID after tombstoning, the new events will append after the tombstone markers. The aggregate will re-compute from the tombstone markers (which produce no state) plus the new events. This works correctly, but you should be aware of it.