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. - Transitive cascade optionally tombstones related streams (e.g., comments 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 transitive tombstone behavior per aggregate type using onTombstone in your spec:
{
"aggregate_types": {
"user": {
"events": { "..." : "..." }
},
"comment": {
"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:
comment:c1where Alice is the actor (author) -- tombstoned (actor: "cascade")comment:c2where Alice is the target (mentioned) -- preserved (target: "preserve")order:o1where Alice is the actor (buyer) -- preserved (actor: "preserve")
Aggregate types without an onTombstone configuration are never scanned during transitive discovery.
How transitive 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 means transitive erasure follows the actor/target relationships already present in your event metadata -- no additional configuration required beyond the onTombstone block.
Tombstone lifecycle
create ──→ pending ──→ executing ──→ completed
│
└──→ 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 |
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.
API
All tombstone endpoints are internal (node-secret authenticated) and scoped to an instance and environment. The full path pattern is:
/internal/instances/:instance_id/:env/tombstone/...
/internal/instances/:instance_id/:env/tombstones/...
These are called by the headnode on behalf of the operator dashboard. You do not call them directly from application code.
Create tombstone
curl -X POST \
http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstone/user/alice \
-H "X-Node-Secret: $NODE_SECRET" \
-H "Content-Type: application/json" \
-d '{
"legal_basis": "gdpr_article_17",
"request_id": "REQ-4521",
"requested_by": {"type": "operator", "id": "admin@company.com"},
"grace_period_days": 7
}'
Path parameters:
| Parameter | Description |
|---|---|
:instance_id |
Instance UUID |
:env |
Environment (prod, staging, test) |
:type |
Aggregate type (e.g., user) |
:id |
Aggregate ID (e.g., alice) |
Body parameters:
| Parameter | Required | Default | Description |
|---|---|---|---|
legal_basis |
No | — | Legal justification (e.g., "gdpr_article_17") |
request_id |
No | — | External request tracking ID |
requested_by |
No | — | Object identifying who requested erasure |
grace_period_days |
No | 3 | Days before execution is allowed (72-hour minimum enforced) |
Returns 201 Created with the tombstone record. Returns 409 Conflict if a tombstone already exists for this aggregate (in pending, executing, or completed status).
List tombstones
curl http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstones \
-H "X-Node-Secret: $NODE_SECRET"
Returns all tombstones for the instance/environment, ordered by creation date (newest first).
Get tombstone
curl http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstones/$TOMBSTONE_ID \
-H "X-Node-Secret: $NODE_SECRET"
Returns the full tombstone record including audit fields (pre_tombstone_root, affected_streams, executed_at).
Cancel tombstone
curl -X DELETE \
http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstones/$TOMBSTONE_ID \
-H "X-Node-Secret: $NODE_SECRET"
Only works while the tombstone is in pending status. Returns 409 Conflict if the tombstone has already begun executing or completed.
Execute tombstone
curl -X POST \
http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstones/$TOMBSTONE_ID/execute \
-H "X-Node-Secret: $NODE_SECRET"
Returns 202 Accepted. Execution runs asynchronously -- poll the GET endpoint for completion.
To force execution before the grace period elapses:
curl -X POST \
"http://localhost:17001/internal/instances/$INSTANCE_ID/$ENV/tombstones/$TOMBSTONE_ID/execute" \
-H "X-Node-Secret: $NODE_SECRET" \
-H "Content-Type: application/json" \
-d '{"force": true}'
Force execution is logged as a warning.
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:
Snapshot root checkpoint -- a Merkle root is computed and stored as
pre_tombstone_rooton the tombstone record. This is the cryptographic proof of the state before erasure.Discover transitive references -- all aggregate types with
onTombstonecascade rules are scanned. Streams where the tombstoned entity appears as actor or target are identified and classified ascascadeorpreserve.Rewrite streams -- the primary stream and all cascade-marked transitive streams are rewritten. Each event is replaced with a
_was_tombstonedmarker. The hash chain is recomputed for each stream. Stream lengths are verified with XLEN guards to prevent concurrent write conflicts. If any rewrite fails, the operation is retried (up to 3 attempts).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 remains in executing status and the failure is logged. Execution can be reattempted.
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": "comment:c1", "event_count": 3, "action": "tombstoned"},
{"key": "comment:c2", "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 comment authored by a user is probably PII; an order placed by a user might need to be retained for financial records. The right answer depends on your domain and legal requirements.
Aggregate-level, not event-level. Tombstones erase entire streams, not individual events. If you need to erase a single event's data, tombstoning will replace all events in that aggregate's stream. Design your aggregate boundaries accordingly.
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.