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
| Field | Type | Description |
|---|---|---|
id | integer | Monotonic PK / sequence — defines chain order |
actor | string | Identity that performed the action (user:123, agent:default, system:scheduler) |
action | string | Action name, e.g. matter.create |
inputs | object | PII-scrubbed action inputs (JSONB) |
outputs | object | PII-scrubbed action outputs (JSONB) |
approval | object | null | Gate outcome if this action required approval |
timestamp | datetime | UTC server-sourced timestamp |
run_id | string | null | Workflow run correlation id |
prev_hash | string | SHA-256 hex of the previous record (zeros for genesis) |
record_hash | string | SHA-256 hex of prev_hash + canonical(record) |
canonical_version | string | Serialization 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:
record_hashmatchesSHA-256(prev_hash || canonical(record))prev_hashmatches the previous record'srecord_hash- No gaps in
idsequence (monotonicity) - All
canonical_versionvalues are recognised
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=45If 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:
| Pattern | Scrubbed 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
| Scale | Rows/year | Strategy |
|---|---|---|
| Single firm, 1,000 matters | ~20,000 | Single table, no partitioning needed |
| Multi-firm (Phase 5) | Rapid growth | PostgreSQL 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
- Hash chain — altering any record invalidates all subsequent hashes
- Append-only — no
UPDATEorDELETEon the audit table; onlyINSERT - Database triggers — a Postgres trigger prevents direct
UPDATE/DELETEonaudit_log - Monotonic IDs — gaps in the sequence indicate tampering or truncation
- Periodic verification — scheduled Celery task runs
verify()and pages on failure
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
| Mistake | Consequence | Fix |
|---|---|---|
| Querying audit records without specifying a time range | Full table scan on large datasets; slow and resource-intensive | Always provide since/until or run_id filters |
| Assuming PII scrubbing is optional | Raw A-numbers or SSNs leak into observability signals | The scrubber runs automatically — never bypass it |
Running UPDATE or DELETE on the audit table | Breaks the hash chain; triggers AuditChainCorruptionError | Audit table is append-only; use export for corrections |
Skipping periodic verify() checks | Chain corruption goes undetected until audit review | Schedule verify() via Celery beat; page on failure |
Verification
After reading this page, verify your understanding:
- Explain why the audit
INSERTis part of the same transaction as the business write - Walk through what
verify()checks and what happens on failure - List the six PII patterns that are automatically scrubbed
- Show how to export audit records for a specific workflow run
Next steps
- RBAC & Privilege Gates — Who can do what, and how privilege is enforced
- Encryption & Secrets — How data is encrypted at rest and in transit
Last updated: 2026-06-01