16 KiB
Audit Module -- Model Reference
This document projects the five audit tables from the master data model reference (data-model.md). It is a projection, not a fork; technical content is identical to the master reference, adapted for standalone readability within the module context.
All tables reside in the audit schema (Decision 113). Cross-module foreign keys use schema-qualified references (Decision 114). Primary keys use UUIDv7 (Decision 30).
audit_logs
An audit_log records actions taken within the system. It provides an immutable trail of who did what, when, and to what. The actor model distinguishes the identity (person or service account) from the credential (session, PAT, API key) used to authenticate. Serves as the complete history for repeated state transition cycles and entitlement toggle history.
The table is partitioned by monthly RANGE on created_at. The composite primary key (log_id, created_at) is required because PostgreSQL enforces that unique constraints on partitioned tables must include all partition key columns. Per-partition uniqueness is acceptable — UUIDv7 collision probability is negligible, and audit records are never referenced by foreign keys from other tables.
Events are classified at write time into one of five retention tiers. The tier determines how long the event is retained before archival or deletion: critical (20 years — financial/patronage), security (7 years — auth/access), compliance (7 years — SOC 2 evidence), operational (1 year — API calls, navigation), debug (90 days — diagnostic). Tier assignment is a write-time classification; changing the tier of an existing event is not expected.
| Field | Type | Purpose |
|---|---|---|
log_id |
UUID | Composite PK with created_at. |
created_at |
TIMESTAMPTZ | When the action occurred. Partition key. Composite PK with log_id. |
actor_type |
VARCHAR(20) | Identity type: person, service_account, system. |
actor_person_id |
UUID | FK -> identity.persons. If actor is a person. |
actor_service_account_id |
UUID | FK -> organization.service_accounts. If actor is a service account. |
actor_credential_type |
VARCHAR(20) | How they authenticated: session (browser), pat (personal access token), api_key (service account key), oidc_client (Keycloak client credentials), system (internal). |
actor_credential_id |
UUID | ID of the credential used (token_id or key_id). NULL for session and system. |
actor_ip |
INET | IP address of the actor. |
actor_user_agent |
TEXT | Browser/client user agent string. |
entity_type |
VARCHAR(50) | What kind of entity was affected. |
entity_id |
UUID | ID of the affected entity. |
org_id |
UUID | FK -> organization.organizations. Organization context, for filtering. |
action |
VARCHAR(50) | What happened (e.g., create, update, delete, login). |
from_status |
VARCHAR(50) | Previous status value for status transition events. NULL for non-status-change actions. Materialized from changes JSONB for query performance — JSONB lacks column-level statistics and requires 2x storage overhead for expression indexes. |
to_status |
VARCHAR(50) | New status value for status transition events. NULL for non-status-change actions. Materialized alongside from_status. |
changes |
JSONB | Details of what changed. Must not contain PII. Status transitions also recorded here as {"status": {"from": "X", "to": "Y"}} for backward compatibility. |
metadata |
JSONB | Additional context. Must not contain PII. |
request_id |
VARCHAR(100) | Request correlation ID for log tracing. |
tier |
VARCHAR(20) | Retention tier classification: critical, security, compliance, operational, debug. Assigned at write time. Determines retention duration via audit_retention_policies. |
severity |
VARCHAR(20) | Event severity: critical, high, medium, low, info. |
status |
VARCHAR(20) | Outcome: success, failure, partial. |
Constraints:
CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'))
CHECK (tier IN ('critical', 'security', 'compliance', 'operational', 'debug'))
Indexing budget (6 indexes):
- 3 composite B-tree:
(entity_type, entity_id, created_at)for entity lookup;(actor_person_id, created_at)and(actor_service_account_id, created_at)for actor lookup;(org_id, action, created_at)for action filtering. - 1 BRIN:
created_atfor time-range scans (BRIN exploits the near-perfect correlation between insert order and timestamp on append-only tables). - 2 partial:
WHERE status = 'failure' AND action = 'login'for failed authentication monitoring;WHERE severity IN ('critical', 'high')for high-severity event alerting.
Write architecture: Governance-critical events (financial transactions, access control changes, authentication events) are written synchronously within the business transaction. Operational and debug events may use buffered batch inserts for throughput efficiency. The specific buffering implementation is deferred to build time.
Polymorphic association rationale: This table contains two intentional bare polymorphic associations that are exceptions to the model's general preference for exclusive arcs:
-
entity_type+entity_id: The target entity can be any of the 53 tables in the system. Neither an exclusive arc (53 nullable FK columns) nor class table inheritance is appropriate. The entity reference is a metadata annotation on an append-only record, not a navigable relational reference. Per the polymorphic pattern policy (Gate 0), metadata annotations on append-only observational tables may use bare polymorphic associations. -
actor_credential_type+actor_credential_id: Only two of five credential types (pat,api_key) have table-backed FK targets. Sessions, OIDC client credentials, and system credentials do not have dedicated tables. The credential reference is informational metadata, not a relational concern. The actor identity side (actor_person_id,actor_service_account_id) uses proper FK columns because identity is a relational concern.
Note that the actor identity columns use proper relational FKs, while the actor credential and entity references use bare polymorphic associations. This split reflects Decision 15 (actor model: identity is a relational concern) and Decision 23 (polymorphism exceptions: metadata annotations on append-only tables).
Relationships:
audit_logs(0..*) -> (0..1)identity.persons(viaactor_person_id): FK -> identity.persons. Logs may reference an acting person.audit_logs(0..*) -> (0..1)organization.service_accounts(viaactor_service_account_id): FK -> organization.service_accounts. Logs may reference an acting service account.audit_logs(0..*) -> (0..1)organization.organizations(viaorg_id): FK -> organization.organizations. Logs may be scoped to an organization.
audit_retention_policies
An audit_retention_policy defines the lifecycle duration for each retention tier. This is a static configuration table, seeded at deployment and rarely changed. Each row maps a tier to its hot, warm, cold, and frozen durations, defining how long audit events in that tier remain at each storage temperature before transitioning to the next.
| Field | Type | Purpose |
|---|---|---|
policy_id |
UUID | Primary key |
tier |
VARCHAR(20) | The retention tier this policy governs. Unique. |
description |
TEXT | Human-readable description of what this tier covers. |
hot_duration_months |
INTEGER | Months in hot storage (primary PostgreSQL, full indexes). |
warm_duration_months |
INTEGER | Months in warm storage (PostgreSQL, reduced indexes). NULL if tier transitions directly to cold. |
cold_duration_months |
INTEGER | Months in cold storage (columnar files outside PostgreSQL). NULL if tier has no cold phase. |
total_retention_months |
INTEGER | Total retention obligation in months. Computed from tier requirements (e.g., 240 for critical). |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Constraints:
tieris unique.
Seed data:
| Tier | Hot | Warm | Cold | Total |
|---|---|---|---|---|
critical |
12 | 72 | 156 | 240 (20yr) |
security |
12 | 36 | 36 | 84 (7yr) |
compliance |
12 | 36 | 36 | 84 (7yr) |
operational |
6 | 6 | -- | 12 (1yr) |
debug |
3 | -- | -- | 3 (90d) |
Relationships:
audit_retention_policiesis a standalone configuration table. No FK relationships. Thetiercolumn is referenced byaudit_logs.tiervia application-level convention, not a foreign key constraint (the audit_logs table is partitioned and avoids cross-table FK dependencies).
audit_legal_holds
An audit_legal_hold records an active legal hold that blocks automated archival or anonymization of audit event data within scope. This is structurally parallel to retention_holds (which protects person PII from premature erasure) but scoped to audit event data by time range, tenant, actor, and entity.
The key distinction: retention_holds protects person PII at the record level — a specific person's data cannot be anonymized while a hold is active. audit_legal_holds protects audit event data at the partition level — audit partitions within the hold's scope cannot be archived, anonymized, or dropped while a hold is active. Both are "hold" mechanisms from the same compliance family, with different targets.
| Field | Type | Purpose |
|---|---|---|
hold_id |
UUID | Primary key |
hold_name |
VARCHAR(255) | Human-readable name for this hold (e.g., "SEC Investigation 2026-Q3", "Member dispute #4412"). |
legal_authority |
VARCHAR(100) | The legal basis or requesting authority (e.g., sec_investigation, subpoena, internal_audit, member_dispute). |
description |
TEXT | Detailed explanation of what triggered the hold and what it protects. |
scope_start |
TIMESTAMPTZ | Earliest created_at of audit events covered by this hold. |
scope_end |
TIMESTAMPTZ | Latest created_at of audit events covered by this hold. NULL for open-ended holds. |
scope_org_id |
UUID | FK -> organization.organizations. If the hold is scoped to a specific organization. NULL for platform-wide holds. |
scope_actor_person_id |
UUID | FK -> identity.persons. If the hold is scoped to a specific actor. NULL for broader holds. |
scope_entity_type |
VARCHAR(50) | If the hold is scoped to a specific entity type. NULL for all entity types. |
scope_entity_id |
UUID | If the hold is scoped to a specific entity. NULL for broader holds. |
hold_placed_at |
TIMESTAMPTZ | When the hold was placed. |
hold_placed_by |
UUID | FK -> identity.persons. Who placed the hold. |
hold_expires_at |
TIMESTAMPTZ | When the hold is scheduled to expire. NULL if expiration depends on a future event. |
hold_released_at |
TIMESTAMPTZ | When the hold was released. NULL if still active. |
hold_released_by |
UUID | FK -> identity.persons. Who released the hold. |
release_reason |
TEXT | Why the hold was released. |
status |
VARCHAR(20) | active, released, expired. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Behavioral contract: The archival pipeline must check for active audit_legal_holds before archiving or dropping any partition. If any active hold's scope overlaps with the partition's time range (and optionally org/actor/entity scope), the partition must not be archived or dropped. This check operates at the partition level, unlike retention_holds which operates at the record level.
Relationships:
audit_legal_holds(0..*) -> (0..1)organization.organizations(viascope_org_id): FK -> organization.organizations. Holds may be scoped to an organization.audit_legal_holds(0..*) -> (0..1)identity.persons(viascope_actor_person_id): FK -> identity.persons. Holds may be scoped to a specific actor.audit_legal_holds(0..*) -> (1)identity.persons(viahold_placed_by): FK -> identity.persons. Holds are placed by a person.audit_legal_holds(0..*) -> (0..1)identity.persons(viahold_released_by): FK -> identity.persons. Holds may be released by a person.
audit_archive_manifest
An audit_archive_manifest tracks the lifecycle state of each audit log partition as it transitions through storage tiers: hot -> warm -> cold -> frozen. This is operational metadata for the archival pipeline — it records where each partition is, when it transitioned, and integrity verification status.
| Field | Type | Purpose |
|---|---|---|
manifest_id |
UUID | Primary key |
partition_name |
VARCHAR(255) | PostgreSQL partition name (e.g., audit_logs_2026_01). Unique. |
partition_start |
TIMESTAMPTZ | Start of the partition's time range. |
partition_end |
TIMESTAMPTZ | End of the partition's time range. |
row_count |
BIGINT | Number of rows in the partition at time of last transition. |
storage_tier |
VARCHAR(20) | Current storage tier: hot, warm, cold, frozen. |
hot_at |
TIMESTAMPTZ | When the partition was created (entered hot tier). |
warm_at |
TIMESTAMPTZ | When transitioned to warm tier. NULL if still hot. |
cold_at |
TIMESTAMPTZ | When transitioned to cold tier (detached from PostgreSQL, exported to columnar files). NULL if not yet cold. |
frozen_at |
TIMESTAMPTZ | When transitioned to frozen tier (long-term archival). NULL if not yet frozen. |
dropped_at |
TIMESTAMPTZ | When the partition was dropped from PostgreSQL. NULL if still attached. |
archive_path |
TEXT | Location of the archived partition file (e.g., S3 path, local path). NULL while hot/warm. |
archive_checksum |
VARCHAR(128) | Integrity checksum of the archived file. NULL while hot/warm. |
archive_verified_at |
TIMESTAMPTZ | When the archive integrity was last verified. |
legal_hold_block |
BOOLEAN DEFAULT FALSE | Whether an active legal hold is preventing this partition from advancing. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Constraints:
partition_nameis unique.storage_tierCHECK:IN ('hot', 'warm', 'cold', 'frozen').
Relationships:
audit_archive_manifestis a standalone operational table. It does not have FK relationships to other tables — it references partitions by name and time range, not by relational join.
audit_outbox
An audit_outbox entry represents an audit event that needs to be delivered to an external consumer (SIEM, analytics pipeline, compliance reporting). The outbox INSERT is performed in the same transaction as the audit event INSERT, guaranteeing at-least-once delivery semantics without distributed transactions.
| Field | Type | Purpose |
|---|---|---|
outbox_id |
UUID | Primary key |
log_id |
UUID | The audit event this outbox entry refers to. Not an FK (audit_logs has a composite PK and is partitioned). |
created_at |
TIMESTAMPTZ | When this outbox entry was created (matches the audit event's created_at). |
destination |
VARCHAR(100) | Target system identifier (e.g., siem, analytics, compliance_export). |
payload |
JSONB | Event payload for the external consumer. May be a projection of the full audit event. |
status |
VARCHAR(20) | pending, delivered, failed, expired. |
attempts |
INTEGER DEFAULT 0 | Number of delivery attempts. |
last_attempt_at |
TIMESTAMPTZ | When delivery was last attempted. |
delivered_at |
TIMESTAMPTZ | When successfully delivered. |
error_message |
TEXT | Last delivery error, if any. |
Constraints:
- Index on
(status, created_at)for the relay query pattern:WHERE status = 'pending' ORDER BY created_at LIMIT $batch_size.
Relay pattern: An external relay process polls the outbox, delivers events to external consumers, and marks entries as delivered. The specific relay implementation (polling interval, batch size, consumer protocol) is deferred to build time.
Relationships:
audit_outboxreferencesaudit_logsbylog_idbut does not use a formal FK constraint. The composite PK onaudit_logs(log_id,created_at) and its partitioned structure make cross-table FK enforcement impractical. The reference is maintained by application-level convention: the outbox INSERT occurs in the same transaction as the audit event INSERT.