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

  1. Request a tombstone for an aggregate (e.g., user:alice).
  2. Grace period (minimum 72 hours) allows cancellation.
  3. Execution replaces all events in the stream with _was_tombstoned markers.
  4. Transitive cascade optionally tombstones related streams (e.g., comments authored by Alice).
  5. 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:c1 where Alice is the actor (author) -- tombstoned (actor: "cascade")
  • comment:c2 where Alice is the target (mentioned) -- preserved (target: "preserve")
  • order:o1 where 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:

  1. Snapshot root checkpoint -- a Merkle root is computed and stored as pre_tombstone_root on the tombstone record. This is the cryptographic proof of the state before erasure.

  2. Discover transitive references -- all aggregate types with onTombstone cascade rules are scanned. Streams where the tombstoned entity appears as actor or target are identified and classified as cascade or preserve.

  3. Rewrite streams -- the primary stream and all cascade-marked transitive streams are rewritten. Each event is replaced with a _was_tombstoned marker. 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).

  4. Clear caches -- cached aggregates for all tombstoned streams are deleted.

  5. Cancel scheduled work -- any pending scheduled events targeting tombstoned streams are cancelled.

  6. Mark completed -- the tombstone record is updated with completed status, affected_streams summary, and executed_at timestamp.

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.

Can't find what you need? support@j17.app