Skip to content

Audit

Art. 30 audit — an append-only trail as the source of truth.

class AuditChainVerifier:
def __init__(session_factory: sessionmaker, audit_events: Table) -> None

Detects 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.

def verify() -> ChainVerification

Walk 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:

  • ChainVerificationverified=True with no broken id when the chained rows form a
  • ChainVerification — single intact list whose every row recomputes to its stored hash
  • ChainVerification — (including the empty/fully-unchained case); otherwise
  • ChainVerificationverified=False naming the first broken row — the earliest
  • ChainVerification — content mismatch along the walk, or the fork/orphan/missing-or-
  • ChainVerification — duplicate-genesis row that proves the list is no longer a single
  • ChainVerification — intact chain.

Raises:

  • AuditIntegrityError — If the trail contains an event_type this version of effaced cannot interpret — all-or-nothing, exactly as read<effaced.DatabaseAuditSink> handles it. An unreadable row cannot be hashed, so the read fails loudly rather than verify a partial trail.
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).
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).

MemberValue
CONSENT_GRANTEDconsent_granted
CONSENT_WITHDRAWNconsent_withdrawn
EXPORT_REQUESTEDexport_requested
EXPORT_COMPLETEDexport_completed
ERASURE_REQUESTEDerasure_requested
ERASURE_LOCAL_COMPLETEDerasure_local_completed
ERASURE_EXPIRY_SCHEDULEDerasure_expiry_scheduled
ERASURE_STEP_SUCCEEDEDerasure_step_succeeded
ERASURE_STEP_FAILEDerasure_step_failed
ERASURE_VERIFIEDerasure_verified
ERASURE_VERIFICATION_FAILEDerasure_verification_failed
ERASURE_EXTERNAL_VERIFIEDerasure_external_verified
ERASURE_EXTERNAL_VERIFICATION_FAILEDerasure_external_verification_failed
ERASURE_COMPLETEDerasure_completed
ERASURE_REQUEUEDerasure_requeued
ERASURE_REPLAYEDerasure_replayed
MANIFEST_SNAPSHOTmanifest_snapshot
RECTIFICATION_REQUESTEDrectification_requested
RECTIFICATION_LOCAL_COMPLETEDrectification_local_completed
RECTIFICATION_STEP_SUCCEEDEDrectification_step_succeeded
RECTIFICATION_STEP_FAILEDrectification_step_failed
RECTIFICATION_COMPLETEDrectification_completed
RESTRICTION_PLACEDrestriction_placed
RESTRICTION_LIFTEDrestriction_lifted
RETENTION_EXPIREDretention_expired

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.

def append(event: AuditEvent) -> None

Durably 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.
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.
class ChainVerification(BaseModel):
first_broken_event_id: UUID | None = None
verified: bool

The 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): The event_id of the earliest row whose recomputed hash differed from its stored hash — where the chain first breaks. None exactly when verified is True.
  • verified (bool): True if every chained row recomputed to its stored hash; False if any chained row’s hash did not match.
def compute_event_hash(event: AuditEvent, prior_hash: str | None) -> str

Hash 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): The event_hash of the immediately preceding chained event, or None for 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 the
  • str — column width.
class DatabaseAuditSink:
def __init__(session_factory: sessionmaker, audit_events: Table) -> None

Append-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.

def append(event: AuditEvent) -> None

Durably 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.
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 an event_type this 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.
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 after since, across all subjects.

Raises:

  • ConfigurationError — If since is timezone-naive — the same guard ReplayPlan.derive> applies to its cutoff, for the same reason.
  • AuditIntegrityError — If the window contains an event_type this version of effaced cannot interpret — all-or-nothing, as in read.