Tamper-evident audit

Every decision the engine makes is recorded in a hash-chained audit log: each entry's hash covers the previous entry's hash, so any change to event content or ordering breaks the chain. It is the evidence behind a Certificate of Erasure. See How it works for the basics; this page covers the operational features.

Verify — and find where it breaks

verify() returns a boolean; verify_report() tells you where a chain breaks.

log.verify_report()
# VerifyResult(ok=True, checked=1042, first_error_sequence=None, …)
#   …or ok=False with first_error_sequence pointing at the tampered entry

Prune safely under retention (checkpoints)

A retention log must be prunable — but a pruned chain no longer starts at sequence 1, which would break verification. Checkpoints fix that: snapshot a run of entries into an immutable, self-chaining Checkpoint, then prune. Verification anchors to the checkpoint instead of the genesis.

cp = log.checkpoint(through_sequence=1000)   # immutable snapshot — persist it
log.prune_through(1000)                       # drop the archived entries

log.verify_report([cp])   # ok=True, anchored_at=1000

On the hosted platform the same applies to the vault chain: POST /audit/checkpoint snapshots it, and GET /audit/verify returns the richer report (with the anchor).

Verify offline (auditor tool)

An auditor can verify a JSONL chain straight from storage — no backend, no API to trust:

dpdpstack verify-chain audit.jsonl --checkpoints cp.jsonl
# OK - verified 2400 entries (anchored at #1000).
#  (exits non-zero and names the broken entry if the chain was tampered with)

Crypto-shred PII in the log ([crypto])

The chain normally holds no PII (subject is an opaque ref). When you must record PII inside an entry, seal it: the PII is encrypted into an opaque token that the entry hash covers. Verification runs on the ciphertext, so you can later destroy the key (right-to-erasure) — the payload becomes unreadable while the chain still verifies.

from dpdpstack.sealing import generate_seal_key

key = generate_seal_key()                       # keep secret; deleting it shreds the data
e = log.record("evidence", subject="user_42",
               private={"aadhaar": "2341 2341 2346"}, seal_key=key)
AuditLog.open_sealed(e, key)                     # -> {"aadhaar": "…"}  (with the key)
log.verify()                                     # True — even after the key is destroyed

Key rotation (zero-downtime): pass a list of keys, newest first. New entries seal with the first key; unsealing tries all, so older-key entries still open. Because the ciphertext is part of the entry hash, chain entries are never re-encrypted — keep an old key around to read old entries, and retire it once they've been pruned or shredded.

new = generate_seal_key()
log.record("evidence", subject="user_43", private={…}, seal_key=[new, key])  # seals with `new`
AuditLog.open_sealed(e, [new, key])              # still opens the old-key entry