Audit trail
Art. 5(2) makes you accountable for demonstrating how you handled personal data; Art. 30 makes you keep records of processing. effaced’s answer is an audit trail that every mechanism writes to: every consent change, every export, every erasure outcome — including failures and abandonment — produces an event. Nothing is silently dropped.
Append-only by construction
Section titled “Append-only by construction”The AuditSink protocol has exactly two methods: append and read.
There is no update method and no delete method, and there never will
be — the surface itself makes tampering unrepresentable, rather than
relying on discipline. The protocol is public API extended additively only
(new optional methods with default implementations); existing custom
sinks must never break on upgrade.
class AuditSink(Protocol): def append(self, event: AuditEvent) -> None: ... def read(self, subject_ref: str) -> Sequence[AuditEvent]: ...append is sync by design: it runs inside the erasure and consent
transaction paths (ADR 0006). An async external sink, if ever needed,
would be an additive separate adapter — never a protocol change.
Events carry references, never rich PII
Section titled “Events carry references, never rich PII”An audit trail that hoards personal data would defeat its purpose — it
must itself survive an erasure. So AuditEvent is deliberately narrow:
event_id— unique, assigned at creation, never reused.event_type— anAuditEventTypemember:CONSENT_GRANTED,CONSENT_WITHDRAWN,EXPORT_REQUESTED,EXPORT_COMPLETED,ERASURE_REQUESTED,ERASURE_STEP_SUCCEEDED,ERASURE_STEP_FAILED,ERASURE_LOCAL_COMPLETED,ERASURE_COMPLETED,MANIFEST_SNAPSHOT.subject_ref— an opaque subject reference, not an email or a name.occurred_at(UTC) and apayloadof short, loggable scalars: counts, table names, versions. Failure payloads carry exception class names only, never messages — database errors embed row values, and the trail stays PII-free.
Adding event types is MINOR; removing or renaming one is MAJOR — old trails must stay readable forever.
DatabaseAuditSink: your database, your custody
Section titled “DatabaseAuditSink: your database, your custody”The default sink writes to the effaced_audit_events table mounted by
bind_tables — zero data leaves your system:
audit = DatabaseAuditSink(session_factory, tables.audit_events)Its two contracts matter operationally:
- Each
appendcommits in its own short transaction. An event survives even when the caller’s surrounding transaction rolls back — anERASURE_REQUESTEDfor an erasure that failed and rolled back is evidence, and evidence is never lost to an unrelated failure. readis all-or-nothing. A trail containing anevent_typethis version of effaced cannot interpret (written by a newer release) raisesAuditIntegrityErrorrather than serving a silently incomplete trail — partial evidence presented as complete would be worse than no answer. Upgrading effaced restores readability; nothing is lost.
read(subject_ref) returns the subject’s full trail, oldest first, with
deterministic ordering — what a regulator asks for first.
Full signatures: API reference.