Files
member-console/design/audit/model.md
Christian Galo 195cd348a9 Update design to use schemas per module and revert to using industry
aligned terminology for discounts and coupons and promo codes.
2026-04-01 03:08:35 -05:00

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_at for 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 (via actor_person_id): FK -> identity.persons. Logs may reference an acting person.
  • audit_logs (0..*) -> (0..1) organization.service_accounts (via actor_service_account_id): FK -> organization.service_accounts. Logs may reference an acting service account.
  • audit_logs (0..*) -> (0..1) organization.organizations (via org_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:

  • tier is 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_policies is a standalone configuration table. No FK relationships. The tier column is referenced by audit_logs.tier via application-level convention, not a foreign key constraint (the audit_logs table is partitioned and avoids cross-table FK dependencies).

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 (via scope_org_id): FK -> organization.organizations. Holds may be scoped to an organization.
  • audit_legal_holds (0..*) -> (0..1) identity.persons (via scope_actor_person_id): FK -> identity.persons. Holds may be scoped to a specific actor.
  • audit_legal_holds (0..*) -> (1) identity.persons (via hold_placed_by): FK -> identity.persons. Holds are placed by a person.
  • audit_legal_holds (0..*) -> (0..1) identity.persons (via hold_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_name is unique.
  • storage_tier CHECK: IN ('hot', 'warm', 'cold', 'frozen').

Relationships:

  • audit_archive_manifest is 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_outbox references audit_logs by log_id but does not use a formal FK constraint. The composite PK on audit_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.