Skip to content

ADR-006: Append-Only Audit Logging in Separate PostgreSQL Database

Property Value
Status Accepted
Date 2026-03-13
Decision Makers Project Team
Source docs/adr/006-append-only-audit.md

Context

The system requires a tamper-evident audit log that records all significant actions for compliance and forensic purposes. The constitution mandates immutability of audit records. Decisions are needed on storage mechanism, data isolation, and retention management strategy.

Key requirements:

  • Audit records must be immutable (no modification or deletion through normal operations).
  • Audit data must be queryable with time-range filters.
  • Retention must be manageable without affecting query performance.
  • Audit data must be isolated from operational data.

Decision

Use a PostgreSQL append-only table partitioned by month for audit logging, stored in a separate database from the operational data.

Design Points

Aspect Decision
Storage PostgreSQL audit.audit_entries table
Immutability INSERT only; UPDATE and DELETE blocked by database triggers
Partitioning Monthly partitions by timestamp column (PARTITION BY RANGE)
Isolation Separate PostgreSQL database from operational data
Retention 1-year minimum; old partitions archived or dropped
GDPR Compliance via anonymization of user identifiers, not deletion
ID Format ULID (time-ordered, sortable)

Partition Naming

Partitions follow the pattern audit_entries_YYYY_MM (e.g., audit_entries_2026_03). Twelve months of partitions are created ahead of time.

Mutation Prevention

Two triggers enforce immutability:

-- Prevents UPDATE operations
CREATE TRIGGER audit_entries_no_update
  BEFORE UPDATE ON audit.audit_entries
  FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_mutation();

-- Prevents DELETE operations
CREATE TRIGGER audit_entries_no_delete
  BEFORE DELETE ON audit.audit_entries
  FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_mutation();

Both triggers call a function that raises an exception: "Audit entries are immutable. UPDATE and DELETE operations are not allowed."


Rationale

  • Immutability requirement from constitution: The append-only constraint directly satisfies the constitutional requirement that audit records cannot be modified or deleted through normal operations.

  • Monthly partitioning enables retention management and query performance: Partitions can be archived or dropped as whole units when they age out of the retention window. Queries filtering by time range benefit from partition pruning.

  • Separate database prevents audit data from being affected by operational DB issues: Operational database maintenance (migrations, restores, performance issues) cannot inadvertently affect audit records. The audit database can have its own backup schedule, replication, and access controls.


Alternatives Considered

Audit log in the same database

Simpler to operate but creates risk of audit data loss during operational DB restores. Also makes it harder to enforce separate access controls.

Immutable object storage (e.g., S3 with Object Lock)

Provides strong immutability guarantees but makes querying difficult and introduces dependency on cloud-specific services.

Dedicated audit service (e.g., Elasticsearch)

Good query capabilities but adds operational complexity and a new technology to the stack.


Consequences

What becomes easier

  • Proving audit integrity for compliance audits.
  • Managing retention by dropping monthly partitions.
  • Querying audit data efficiently with time-range filters (partition pruning).
  • Isolating audit access controls from operational database.
  • Exporting audit data for compliance reporting.

What becomes more difficult

  • Operating a second PostgreSQL database.
  • Ensuring audit writes succeed even when the operational database is under load.
  • Managing cross-database consistency if audit records reference operational entities.

Implementation

  • Schema: db/migrations/004_audit_entries.sql
  • Audit writer: plugins/audit-enterprise/src/writer/writer.ts
  • Query handler: plugins/audit-enterprise/src/query/query-method.ts
  • Export: plugins/audit-enterprise/src/export/user-data.ts
  • Routes: plugins/audit-enterprise/src/routes.ts
  • Constants: AUDIT_MIN_RETENTION_YEARS = 1, AUDIT_QUERY_TIMEOUT_MS = 10000, AUDIT_DEFAULT_PAGE_SIZE = 100, AUDIT_MAX_PAGE_SIZE = 1000