Now domain-agnostic — configure any practice with Domain Packs (immigration is the reference pack)
Audit Log

Audit & Logging

Audience: Compliance officers, security reviewers, and engineers debugging workflow outcomes.
What you will accomplish: Understand the tamper-evident audit log — its schema, hash chain, PII scrubbing, and verification — so you can query, export, or verify audit records.
Prerequisites: Read Security overview first.
Estimated time: ~8 minutes.

The audit log is the single source of truth for every state-changing action in the system. It is append-only, hash-chained, and tamper-evident.

Core invariant

Every state-changing action writes an immutable audit record before it is considered complete.

The UnitOfWork enforces this: the audit INSERT is part of the same database transaction as the business write. If the audit write fails, the entire transaction rolls back.

Audit record schema

FieldTypeDescription
idintegerMonotonic PK / sequence — defines chain order
actorstringIdentity that performed the action (user:123, agent:default, system:scheduler)
actionstringAction name, e.g. matter.create
inputsobjectPII-scrubbed action inputs (JSONB)
outputsobjectPII-scrubbed action outputs (JSONB)
approvalobject | nullGate outcome if this action required approval
timestampdatetimeUTC server-sourced timestamp
run_idstring | nullWorkflow run correlation id
prev_hashstringSHA-256 hex of the previous record (zeros for genesis)
record_hashstringSHA-256 hex of prev_hash + canonical(record)
canonical_versionstringSerialization format version (v1 currently)

Hash chain

The audit log forms a hash chain:

record_0: prev_hash = 0x0000..., record_hash = SHA-256(prev_hash || canonical(record_0))
record_1: prev_hash = record_0.record_hash, record_hash = SHA-256(prev_hash || canonical(record_1))
record_2: prev_hash = record_1.record_hash, record_hash = SHA-256(prev_hash || canonical(record_2))
...

Canonical serialisation

ADR-001 specifies sorted-key JSON with no whitespace as the canonical form:

json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)

The canonical_version field is included in the record body before hashing. If the serialisation format ever changes, a new canonical_version value will be introduced and verify() will handle mixed-version chains.

Verification

The AuditService.verify() function walks the chain and checks:

  1. record_hash matches SHA-256(prev_hash || canonical(record))
  2. prev_hash matches the previous record's record_hash
  3. No gaps in id sequence (monotonicity)
  4. All canonical_version values are recognised
Verify audit chain integrity
uv run python -c "from cam.core.audit.service import AuditService; AuditService.verify()"

Expected output:

2026-06-01 14:32:01 [info] audit_chain_verified
    records_checked=1247
    head_hash=abc123...
    elapsed_ms=45

If verification fails, the service raises AuditChainCorruptionError with the first mismatched record id.

PII scrubbing

Before any value enters inputs or outputs, it passes through the shared PII scrubber:

PatternScrubbed to
A-numbers[[REDACTED:A-NUMBER]]
SSNs[[REDACTED:SSN]]
Passport numbers[[REDACTED:PASSPORT]]
Dates of birth[[REDACTED:DOB]]
Email addresses[[REDACTED:EMAIL]]
Phone numbers[[REDACTED:PHONE]]

This applies across all three observability signals: structured logs, OpenTelemetry spans, and Prometheus labels.

Approval records

When an action requires approval, the approval field captures:

{
  "decision": "approve",
  "approver": "user:attorney_42",
  "token": "tok_abc123",
  "channel": "web",
  "timestamp": "2026-06-01T14:30:00Z",
  "comment": "LGTM"
}

Rejections are recorded the same way, with decision: "reject". Rejected actions do not write a business-state change — only the audit record.

Querying the audit log

By workflow run

records = await audit_service.query(run_id="run_abc123")

By time range

records = await audit_service.query(
    since=datetime(2026, 6, 1, tzinfo=UTC),
    until=datetime(2026, 6, 2, tzinfo=UTC),
)

By actor

records = await audit_service.query(actor="agent:default")

Export

The audit log is exportable as:

  • ND-JSON — one record per line, machine-readable
  • CSV — for spreadsheet review
  • PDF — tamper-evident signed report (future)

Export commands:

# ND-JSON
await audit_service.export(format="ndjson", path="/tmp/audit_2026-06.ndjson")
 
# CSV
await audit_service.export(format="csv", path="/tmp/audit_2026-06.csv")

Scaling considerations

ScaleRows/yearStrategy
Single firm, 1,000 matters~20,000Single table, no partitioning needed
Multi-firm (Phase 5)Rapid growthPostgreSQL range partitioning by month

For multi-tenancy, the planned migration creates monthly partitions via Alembic and auto-creates future partitions via a monthly Celery beat task.

Anti-tamper measures

  1. Hash chain — altering any record invalidates all subsequent hashes
  2. Append-only — no UPDATE or DELETE on the audit table; only INSERT
  3. Database triggers — a Postgres trigger prevents direct UPDATE/DELETE on audit_log
  4. Monotonic IDs — gaps in the sequence indicate tampering or truncation
  5. Periodic verification — scheduled Celery task runs verify() and pages on failure
Audit chain corruption is a safety incident

If verify() ever fails, the system pages the on-call engineer immediately. The chain is the foundation of trust — its integrity is non-negotiable.

Common mistakes

MistakeConsequenceFix
Querying audit records without specifying a time rangeFull table scan on large datasets; slow and resource-intensiveAlways provide since/until or run_id filters
Assuming PII scrubbing is optionalRaw A-numbers or SSNs leak into observability signalsThe scrubber runs automatically — never bypass it
Running UPDATE or DELETE on the audit tableBreaks the hash chain; triggers AuditChainCorruptionErrorAudit table is append-only; use export for corrections
Skipping periodic verify() checksChain corruption goes undetected until audit reviewSchedule verify() via Celery beat; page on failure

Verification

After reading this page, verify your understanding:

  1. Explain why the audit INSERT is part of the same transaction as the business write
  2. Walk through what verify() checks and what happens on failure
  3. List the six PII patterns that are automatically scrubbed
  4. Show how to export audit records for a specific workflow run

Next steps


Last updated: 2026-06-01