Audit
Art. 30 audit — an append-only trail as the source of truth.
AuditChainVerifier
Section titled “AuditChainVerifier”class AuditChainVerifier: def __init__(session_factory: sessionmaker, audit_events: Table) -> NoneDetects out-of-band modification of recorded audit rows (ADR 0028).
A standalone read-only verifier — deliberately not a method on the
AuditSink protocol (which is append-only by
construction and extends additively only). It follows the
ReplaySource precedent: a verification capability is a
separate object reading through the public storage surface, not a
widening of the sink contract. It writes nothing and appends no event —
verifying the trail must not modify it.
It follows the chain as a linked list keyed by insertion — each row’s
prior_hash points at its predecessor’s event_hash, exactly as the
sink wrote it — never by occurred_at (which is caller-supplied and
backdatable, so it cannot order the chain). Starting from the genesis row
(prior_hash IS NULL) it walks predecessor→successor pointers,
recomputing each row’s hash from its stored prior_hash and content,
and reports the first row whose stored hash does not match — the point an
edit, deletion, or reordering breaks the chain. A fork (two rows citing
the same predecessor, e.g. concurrent appends) and an orphan (a chained
row unreachable from genesis, e.g. a deleted link) are likewise breaks,
surfaced and never silently healed.
Rows written before ADR 0028 (or by a custom sink that does not chain) carry no stored hash; they are unchained — skipped entirely, not failed. Absence of a hash is absence of evidence, not evidence of tampering, so a trail with no chained rows verifies vacuously.
This detects modification of recorded rows; it does not prevent it. A writer with table access can recompute the chain forward from any row they edit. It is a mechanism, never a determination that the trail — or the deployment — is secure or compliant.
AuditChainVerifier.verify
Section titled “AuditChainVerifier.verify”def verify() -> ChainVerificationWalk the trail’s hash chain and return the verdict.
Reads every chained row (event_hash not NULL), follows the
linked list from genesis (prior_hash IS NULL) along
predecessor→successor pointers, and recomputes each row’s hash from
its stored prior_hash and content. Unchained rows (NULL
event_hash) are ignored; a trail with no chained rows verifies
vacuously.
Returns:
ChainVerification—verified=Truewith no broken id when the chained rows form aChainVerification— single intact list whose every row recomputes to its stored hashChainVerification— (including the empty/fully-unchained case); otherwiseChainVerification—verified=Falsenaming the first broken row — the earliestChainVerification— content mismatch along the walk, or the fork/orphan/missing-or-ChainVerification— duplicate-genesis row that proves the list is no longer a singleChainVerification— intact chain.
Raises:
AuditIntegrityError— If the trail contains anevent_typethis version of effaced cannot interpret — all-or-nothing, exactly asread<effaced.DatabaseAuditSink>handles it. An unreadable row cannot be hashed, so the read fails loudly rather than verify a partial trail.
AuditEvent
Section titled “AuditEvent”class AuditEvent(BaseModel): event_id: UUID event_type: AuditEventType occurred_at: datetime payload: dict[str, str | int | bool] = Field(default_factory=dict) subject_ref: str = Field(min_length=1, max_length=255)One immutable entry in the audit trail.
Events carry references and metadata, never rich PII — an audit trail
that itself hoards personal data would defeat its purpose. payload
values are intentionally restricted to short, loggable scalars.
Fields:
- event_id (
UUID): Unique id, assigned at creation, never reused. - event_type (
AuditEventType): What happened. - occurred_at (
datetime): When it happened (UTC). - payload (
dict[str, str | int | bool]): Small structured details (counts, table names, versions). - subject_ref (
str): Opaque subject reference (NOT an email or name).
AuditEventType
Section titled “AuditEventType”class AuditEventType(StrEnum): ...Every kind of event the audit trail records.
Adding members is a MINOR change; removing or renaming is MAJOR (old trails must stay readable forever).
| Member | Value |
|---|---|
CONSENT_GRANTED | consent_granted |
CONSENT_WITHDRAWN | consent_withdrawn |
EXPORT_REQUESTED | export_requested |
EXPORT_COMPLETED | export_completed |
ERASURE_REQUESTED | erasure_requested |
ERASURE_LOCAL_COMPLETED | erasure_local_completed |
ERASURE_EXPIRY_SCHEDULED | erasure_expiry_scheduled |
ERASURE_STEP_SUCCEEDED | erasure_step_succeeded |
ERASURE_STEP_FAILED | erasure_step_failed |
ERASURE_VERIFIED | erasure_verified |
ERASURE_VERIFICATION_FAILED | erasure_verification_failed |
ERASURE_EXTERNAL_VERIFIED | erasure_external_verified |
ERASURE_EXTERNAL_VERIFICATION_FAILED | erasure_external_verification_failed |
ERASURE_COMPLETED | erasure_completed |
ERASURE_REQUEUED | erasure_requeued |
ERASURE_REPLAYED | erasure_replayed |
MANIFEST_SNAPSHOT | manifest_snapshot |
RECTIFICATION_REQUESTED | rectification_requested |
RECTIFICATION_LOCAL_COMPLETED | rectification_local_completed |
RECTIFICATION_STEP_SUCCEEDED | rectification_step_succeeded |
RECTIFICATION_STEP_FAILED | rectification_step_failed |
RECTIFICATION_COMPLETED | rectification_completed |
RESTRICTION_PLACED | restriction_placed |
RESTRICTION_LIFTED | restriction_lifted |
RETENTION_EXPIRED | retention_expired |
AuditSink
Section titled “AuditSink”Protocol — implement these members in your own class; do not subclass.
class AuditSink(Protocol): ...Anything that can durably append and read back audit events.
This protocol is public API. It is extended additively only (new optional methods with default implementations) — existing custom sinks must never break on upgrade.
AuditSink.append
Section titled “AuditSink.append”def append(event: AuditEvent) -> NoneDurably append one event.
Must be atomic per event and must never overwrite anything. Sync by design — appends run inside the erasure/consent transaction path (ADR 0006); an async external sink would be an additive separate adapter, never a change to this protocol.
Args:
- event (
AuditEvent): The event to persist.
AuditSink.read
Section titled “AuditSink.read”def read(subject_ref: str) -> Sequence[AuditEvent]Read all events for one subject, oldest first.
Args:
- subject_ref (
str): The opaque subject reference to filter by.
Returns:
Sequence[AuditEvent]— The subject’s full trail — what a regulator asks for first.
ChainVerification
Section titled “ChainVerification”class ChainVerification(BaseModel): first_broken_event_id: UUID | None = None verified: boolThe verdict of recomputing an audit trail’s tamper-evidence chain.
Produced by AuditChainVerifier. It reports whether the
recomputed hash chain matched what was stored — i.e. whether any chained
row was modified out of band since it was written (ADR 0028). It is
detection: verified=True means no break was found in the chained
rows read, never a determination that the trail — or the deployment — is
secure or compliant.
Legacy rows and custom-sink rows with no stored hash are unchained:
they are skipped, not failed. A trail that is entirely unchained
therefore verifies vacuously (verified=True,
first_broken_event_id=None) — absence of a hash is absence of
evidence, not evidence of tampering.
Fields:
- first_broken_event_id (
UUID | None): Theevent_idof the earliest row whose recomputed hash differed from its stored hash — where the chain first breaks.Noneexactly whenverifiedisTrue. - verified (
bool):Trueif every chained row recomputed to its stored hash;Falseif any chained row’s hash did not match.
compute_event_hash
Section titled “compute_event_hash”def compute_event_hash(event: AuditEvent, prior_hash: str | None) -> strHash one event chained to its predecessor’s hash (ADR 0028).
The tamper-evidence primitive: an event’s hash binds its own load-bearing content to the prior event’s hash, so editing any field of any recorded row — or reordering rows — changes its recomputed hash and breaks every later link. Recomputing the whole chain therefore detects an out-of-band modification and localizes the first break. It makes such a modification detectable, not impossible — a writer with table access can recompute the chain forward.
The encoding is canonical, deterministic, and order-sensitive, and is
frozen as audit behaviour (ADR 0028 / widened SemVer): the event’s
event_id, event_type, subject_ref, occurred_at, and
payload are serialized with the prior_hash into a single JSON
object with sorted keys and the tightest separators, then SHA-256 hex
digested. event_id is encoded as its canonical UUID string and
occurred_at is normalized to a UTC, offset-free wall-clock string
(see _canonical_occurred_at) so the digest is stable however a
timestamptz round-trips it — aware on psycopg, naive on SQLite — and
the value hashed at append time matches the value verified after a
read-back. payload is already restricted to short scalars
(AuditEvent), for which JSON and python serialization
coincide.
Args:
- event (
AuditEvent): The event to hash. Never mutated; no field is read that the caller-facing model does not already expose. - prior_hash (
str | None): Theevent_hashof the immediately preceding chained event, orNonefor the first chained event in a trail (or when the predecessor is an unchained legacy row).
Returns:
str— The lowercase hex SHA-256 digest — 64 characters, matching thestr— column width.
DatabaseAuditSink
Section titled “DatabaseAuditSink”class DatabaseAuditSink: def __init__(session_factory: sessionmaker, audit_events: Table) -> NoneAppend-only audit storage in the application’s own database.
The default sink: zero data leaves the user’s system in OSS mode. Rows
are insert-only; the table carries no update path and the sink exposes
none. Each append commits in its own short transaction (ADR
0006), so an event survives even when the caller’s surrounding
transaction later rolls back — audit evidence is never lost to an
unrelated failure.
Each appended row also carries a tamper-evidence hash chain (ADR 0028):
its event_hash binds its content to the prior row’s hash, so an
out-of-band edit of any recorded row is detectable (not prevented) by
AuditChainVerifier.
DatabaseAuditSink.append
Section titled “DatabaseAuditSink.append”def append(event: AuditEvent) -> NoneDurably append one event (insert-only), extending the hash chain.
Commits immediately in a transaction of its own. A duplicate
event_id raises the database’s integrity error — an existing
row is never overwritten.
The hash chain is a pure linked list keyed by insertion, never by
occurred_at (ADR 0028): occurred_at is caller-supplied and
backdatable through the consent/restriction ledgers, so it cannot
order the chain. Within this same transaction the current tail is
read — the one chained row whose event_hash no other row cites as
its prior_hash — and the new event chains to it
(prior_hash = tail.event_hash); the first chained event chains to
None. The chain extends atomically with the insert.
The per-append transaction is the serialization point. Under
genuinely concurrent appends two rows may both read the same tail and
chain to it, forking the list; AuditChainVerifier
detects that fork on read — surfaced, never silently healed.
Deployments needing a strictly linear chain serialize their audit
writes (a single writer, or an advisory lock around append).
Args:
- event (
AuditEvent): The event to persist.
DatabaseAuditSink.read
Section titled “DatabaseAuditSink.read”def read(subject_ref: str) -> Sequence[AuditEvent]Read one subject’s trail, oldest first.
Ordering is by occurred_at, ties broken by event_id so
repeated reads always agree.
Args:
- subject_ref (
str): The opaque subject reference to filter by.
Returns:
Sequence[AuditEvent]— All events recorded for the subject.
Raises:
AuditIntegrityError— If the trail contains anevent_typethis version of effaced cannot interpret (recorded by a newer release). This is deliberately all-or-nothing: one unreadable entry fails the whole read rather than serving a silently incomplete trail — partial evidence presented as complete would be worse than no answer. Upgrading effaced restores readability; nothing is lost.
DatabaseAuditSink.read_since
Section titled “DatabaseAuditSink.read_since”def read_since(since: datetime) -> Sequence[AuditEvent]Read every subject’s events from since onward, oldest first.
The ReplaySource capability (ADR 0023): the
window a backup-replay derivation consumes. The boundary is
inclusive (occurred_at >= since) — matching the replay rule
that an erasure at exactly the backup instant is replayed — and
ordering ties in occurred_at resolve by event_id, exactly
as read does.
Args:
- since (
datetime): The instant to read from, inclusive. Must be timezone-aware — the trail’s timestamps are UTC, and a naive comparison could silently shift the window boundary by the session offset, dropping events from the read.
Returns:
Sequence[AuditEvent]— All events at or aftersince, across all subjects.
Raises:
ConfigurationError— Ifsinceis timezone-naive — the same guardReplayPlan.derive>applies to its cutoff, for the same reason.AuditIntegrityError— If the window contains anevent_typethis version of effaced cannot interpret — all-or-nothing, as inread.