audit — Phase 10
Schema locked 2026-06-10. 7 tables, 118 cols: audit_log (22), data_subject_request (17), data_breach_incident (20), compliance_task (15), dpa_agreement (15), subprocessor (13), audit_export_job (16).
Audit has two clusters. Cluster A: the cross-cutting integrity-chained event log (audit_log). Cluster B: the compliance-workflow platform (data_subject_request, data_breach_incident, compliance_task, dpa_agreement, subprocessor, audit_export_job).
Design principles (document at every session touching this schema):
- Reference, don't copy:
audit_logcaptures cross-cutting change events that have NO existing trail — entity edits, config changes, permission changes, access events not already recorded elsewhere. For domains with authoritative append-only trails already built (crm.customer_consent,identity.identity_access_event,inventory.stock_movement,billing.ar_payment_application,payments.stripe_event_log,pos.sale_line_tax), Audit does NOT re-log — it POINTS at them viasource_table+source_ref. Single source of truth per trail. - Integrity chain (per-tenant, async):
audit_logrows carrysequence_number+row_hash+prev_hash, chained per tenant (not global — tenants do not serialize against each other). Hash =SHA-256(prev_hash || canonical_row_payload). The row is written immediately; the hash is linked in a tight async step.row_hash/prev_hashare null-on-insert and set once (null → value, never mutate). Daily verification job walks the chain per tenant. Unchained-row contract:row_hash IS NULLis acceptable ONLY for the tail of a tenant's sequence — rows awaiting the async chain step within the expected SLA. A row whererow_hash IS NULLand any later-sequenced row for the same tenant hasrow_hashset is a chain break — the verification job must alert. The(tenant_id, sequence_number)unique guarantees ordering even before the hash links, so a gap is always detectable. - Store full, mask at read:
before_state/after_stateare stored complete for forensic integrity. PII masking is a read-time access-control layer, not a write-time redaction.granularity_levelcontrols what is logged (all / sensitive-only / minimal per tenant config inadmin.tenant_setting), not whether it is masked. - Audit owns compliance reporting: GDPR/SAR responses, breach notifications, and regulator-bound exports live in Audit. The Reporting module (Module 6) owns operational business-intelligence reports only. These are different audiences (compliance officers vs. operations staff) and different data shapes.
- Event isolation:
audit.*events are not subscribed by operational modules — this prevents event cycles.audit.log_entry_createdand similar events are internal to the Audit module. - No audit-side config tables: AuditService reads retention period, granularity, and access-level config from
admin.tenant_setting(the catch-all key-value store already built). Audit owns no config tables.
Deferred items:
- AI anomaly / suspicious-activity detection (spec 8.5, 11.1) — dependency on the AI module (not built); add when AI module exists.
- SOC 2 Type II readiness report — year-2 per spec.
subprocessor: index ondpa_id— add when "subprocessors by DPA" query is needed.audit_export_job:(tenant_id, status)composite index — add when export-job dashboard query is defined.data_subject_request/compliance_task/data_breach_incident:(tenant_id, due_date/detected_at)composite indexes — add when per-tenant time-window queries are profiled.
Cut (not deferred — wrong for Vrida):
- HR / labor audit — HR is permanently out of scope.
- Nursery pesticide / USDA compliance records — nursery-specific; generic-first; a compliance_document category code at most.
- Tax-rate-change audit — Stripe Tax owns calculation; no rate tables exist.
- Tenant compliance docs — already
admin.compliance_document. - Vrida's own certification display — Platform concern, not tenant-audit tables.
Cross-Phase FK seams:
| Column | References | Status |
|---|---|---|
*.tenant_id |
platform.tenant |
Locked — enforced |
audit_log.actor_user_id |
identity.identity_user |
Locked — enforced (nullable; system/automated events have no actor) |
data_subject_request.data_subject_customer_id |
crm.customer |
Locked — enforced (nullable; subject may not be a linked customer) |
data_subject_request.handled_by_user_id |
identity.identity_user |
Locked — enforced (nullable) |
data_breach_incident.discovered_by_user_id |
identity.identity_user |
Locked — enforced (nullable) |
compliance_task.assigned_to_user_id |
identity.identity_user |
Locked — enforced (nullable) |
dpa_agreement.supersedes_dpa_id |
audit.dpa_agreement |
Intra-schema — enforced (nullable) |
subprocessor.dpa_id |
audit.dpa_agreement |
Intra-schema — enforced (nullable) |
audit_export_job.requested_by_user_id |
identity.identity_user |
Locked — enforced (nullable) |
audit_log.source_table / source_ref |
Any module's authoritative trail (e.g. crm.customer_consent, inventory.stock_movement) |
Polymorphic pointer — NOT enforced FK; source_table is the discriminator (e.g. 'crm.customer_consent'); loose by design |
audit_export_job.export_ref |
Files / R2 (file key text) | NOT CLOSED — Files module not built; text seam only |
data_subject_request.response_artifact_ref |
Files / R2 (file key text) | NOT CLOSED — Files module not built; text seam only |
dpa_agreement.document_ref |
Files / R2 (file key text) | NOT CLOSED — Files module not built; text seam only |
audit.audit_log — 22 cols
The universal cross-cutting change-event log. Captures entity edits, config changes, permission changes, access events, and business-event records that have no authoritative trail in another module. For events that DO have an authoritative trail in another module (consent, stock movements, payment events, billing applications), the row POINTS at that trail via source_table + source_ref rather than duplicating the data.
Append-only — no updated_at, no deleted_at. Rows are never modified or deleted. The only post-insert write is the async hash-chain step: row_hash and prev_hash transition NULL → value exactly once after insert. They never mutate after that.
RLS: tenant-isolated on tenant_id. Read access gated to compliance-owner roles; before_state / after_state PII masking is enforced at the read layer by AuditService, not at the table level.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
sequence_number |
bigint | NOT NULL | — | Per-tenant monotonic sequence. The chain order for this tenant. Combined with tenant_id forms the uniqueness anchor. |
actor_user_id |
UUID | nullable | — | FK → identity.identity_user; NULL for system-generated or automated events |
actor_type |
text | NOT NULL | 'user' |
CHECK IN ('user','system','api','integration') |
action |
text | NOT NULL | — | CHECK IN ('create','update','delete','view','export','login','logout','permission_change','config_change','other') |
entity_type |
text | NOT NULL | — | The kind of entity changed (e.g. 'customer', 'price_rule', 'tenant_setting', 'role'). Free text — generic across modules. |
entity_ref |
UUID | nullable | — | The changed entity's id. NULL for events without a single target entity. |
entity_module |
text | nullable | — | Which schema/module owns the entity (e.g. 'crm', 'inventory'). |
source_table |
text | nullable | — | When this entry POINTS at an existing authoritative trail rather than duplicating it — the fully-qualified table name (e.g. 'crm.customer_consent'). NULL for events logged directly. |
source_ref |
UUID | nullable | — | The id of the row in source_table. Polymorphic; not enforced FK (cross-schema by design). |
before_state |
JSONB | nullable | — | Full prior entity state. Stored complete — PII masking is a read-time layer, not applied here. NULL for creates and events with no prior state. Example: {"id":"<uuid>","name":"Acme Landscaping","credit_limit_cents":50000}. Must be NULL when source_table is set (pointer mode). |
after_state |
JSONB | nullable | — | Full new entity state. NULL for deletes and events with no subsequent state. Example: {"id":"<uuid>","name":"Acme Landscaping","credit_limit_cents":75000}. Must be NULL when source_table is set (pointer mode). |
change_summary |
text | nullable | — | Human-readable description of what changed (e.g. 'credit_limit_cents changed 50000 → 75000'). |
granularity_level |
text | NOT NULL | 'full' |
CHECK IN ('full','sensitive','minimal') — records what granularity was active when this entry was written, per tenant config in admin.tenant_setting. |
ip_address |
text | nullable | — | Source IP address at time of event. |
user_agent |
text | nullable | — | Browser / client user-agent string. |
session_ref |
text | nullable | — | Identity session reference (Supabase session ID or API key ref) for correlated-event queries. |
row_hash |
text | nullable | — | SHA-256(prev_hash || canonical_row_payload). NULL on insert; set once by the async chain step. Never mutates after linking. |
prev_hash |
text | nullable | — | row_hash of the preceding row in this tenant's sequence. NULL for the first row of a tenant's chain. Set alongside row_hash in the async chain step. |
occurred_at |
timestamptz | NOT NULL | — | When the event happened (business time). May differ from created_at for buffered or replayed events. |
created_at |
timestamptz | NOT NULL | now() |
When this row was inserted into the log. |
Table-level CHECKs:
CHECK ((source_table IS NULL) = (source_ref IS NULL))— pointer-pair:source_tableandsource_refmust be both set or both NULL; one without the other is silently malformedCHECK ((source_table IS NULL) OR (before_state IS NULL AND after_state IS NULL))— pointer mode (source_tableIS NOT NULL) and copy mode (before_state/after_stateset) are mutually exclusive; whenaudit_logpoints at an existing authoritative trail it does NOT also copy state — DB-level guard against Audit duplicating existing consent/access/stock trails
Indexes:
- PK on
id - on (
tenant_id) - UNIQUE on (
tenant_id,sequence_number) — per-tenant chain integrity anchor; one row per sequence position per tenant - on (
tenant_id,occurred_at) — primary compliance/SAR/export query: tenant + date range - on (
actor_user_id) - on (
entity_type,entity_ref) - on (
action) - on (
occurred_at) — cross-tenant ops jobs - on (
source_table,source_ref) WHEREsource_table IS NOT NULL— cross-reference lookups from authoritative trails
audit.data_subject_request — 17 cols
GDPR / CCPA rights workflow. One row per data subject request (access, erasure, rectification, restriction, objection, portability, opt-out). Tracks the full lifecycle from receipt through verification, handling, and response within the statutory deadline.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
request_type |
text | NOT NULL | — | CHECK IN ('access','erasure','rectification','restriction','objection','portability','opt_out') |
data_subject_customer_id |
UUID | nullable | — | FK → crm.customer; NULL if the subject is not a linked customer (e.g. anonymous inquiry) |
data_subject_identifier |
text | nullable | — | Email, name, or other identifier when the subject is not linked to a customer record |
status |
text | NOT NULL | 'received' |
CHECK IN ('received','verifying','in_progress','completed','rejected','withdrawn') |
received_at |
timestamptz | NOT NULL | — | When the request was received (starts the statutory clock) |
verification_method |
text | nullable | — | How the requester's identity was verified (e.g. 'email_link', 'id_document') |
due_date |
date | NOT NULL | — | Statutory deadline — GDPR 30 days; CCPA 45 days. Computed at receive time. |
completed_at |
timestamptz | nullable | — | When the request was fulfilled or rejected |
response_summary |
text | nullable | — | Description of what was provided or why rejected |
response_artifact_ref |
text | nullable | — | Files / R2 key for the data package delivered to the subject. NOT CLOSED — Files module not built; text seam. |
rejection_reason |
text | nullable | — | Reason for rejection (e.g. 'unverified_identity', 'no_data_found') |
handled_by_user_id |
UUID | nullable | — | FK → identity.identity_user — staff member managing this request |
Indexes:
- PK on
id - on (
tenant_id) - on (
status) WHEREstatus IN ('received','verifying','in_progress')— active queue - on (
due_date) — deadline sweep - on (
data_subject_customer_id) - on (
request_type)
audit.data_breach_incident — 20 cols
Data breach detection, classification, containment, and regulator / subject notification workflow. Tracks the incident from detection through resolution with GDPR's 72-hour regulator notification clock.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
title |
text | NOT NULL | — | Short incident title |
description |
text | nullable | — | Narrative description of what happened |
severity |
text | NOT NULL | — | CHECK IN ('low','medium','high','critical') |
status |
text | NOT NULL | 'detected' |
CHECK IN ('detected','investigating','contained','notifying','resolved','closed') |
detected_at |
timestamptz | NOT NULL | — | When the breach was first detected (starts the 72h GDPR clock) |
discovered_by_user_id |
UUID | nullable | — | FK → identity.identity_user — who detected it; NULL for automated detection |
affected_data_categories |
JSONB | nullable | — | Array of data category labels (e.g. ["customer_pii","payment_data"]) |
affected_subject_count |
integer | nullable | — | Estimated number of affected data subjects; may be unknown initially |
regulator_notification_required |
boolean | NOT NULL | false |
Whether GDPR Article 33 / CCPA notification to regulator is required |
regulator_notification_due_at |
timestamptz | nullable | — | 72 hours after detected_at when notification is required (GDPR standard). Must be set when regulator_notification_required = true — enforced by table-level CHECK. |
regulator_notified_at |
timestamptz | nullable | — | When regulator was notified |
subjects_notified_at |
timestamptz | nullable | — | When affected subjects were notified |
containment_summary |
text | nullable | — | What was done to contain the breach |
resolution_summary |
text | nullable | — | Root cause and remediation steps |
resolved_at |
timestamptz | nullable | — | When the incident reached a terminal state |
Table-level CHECKs:
CHECK ((regulator_notification_required = false) OR (regulator_notification_due_at IS NOT NULL))— when regulator notification is required, the statutory 72-hour GDPR clock deadline must be set at the same time; it cannot be silently NULL
Indexes:
- PK on
id - on (
tenant_id) - on (
status) WHEREstatus NOT IN ('resolved','closed')— open incidents - on (
severity) - on (
regulator_notification_due_at) WHEREregulator_notification_due_at IS NOT NULL— deadline sweep - on (
detected_at)
audit.compliance_task — 15 cols
Deadline-tracked compliance obligations: SAR response deadlines, breach notification windows, DPA review cycles, retention-policy reviews, and general compliance items. Links to the source record driving the obligation.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
title |
text | NOT NULL | — | Short task title |
description |
text | nullable | — | Optional detail |
task_type |
text | NOT NULL | — | CHECK IN ('sar_deadline','breach_notification','dpa_review','retention_review','certification','general') |
related_module |
text | nullable | — | Which Audit sub-table this task tracks (e.g. 'data_subject_request', 'data_breach_incident') |
related_ref |
UUID | nullable | — | The id of the related record (polymorphic; not enforced FK — intra-module loose ref) |
status |
text | NOT NULL | 'open' |
CHECK IN ('open','in_progress','completed','cancelled') — 'overdue' is not stored; computed at query: status IN ('open','in_progress') AND due_date < now(). Stored derived state silently lies when the batch job lags. |
due_date |
date | NOT NULL | — | Task deadline |
priority |
text | NOT NULL | 'medium' |
CHECK IN ('low','medium','high') |
assigned_to_user_id |
UUID | nullable | — | FK → identity.identity_user |
completed_at |
timestamptz | nullable | — | When the task was completed |
Table-level CHECKs:
CHECK ((related_module IS NULL) = (related_ref IS NULL))— relation pair:related_moduleandrelated_refmust be both set or both NULL; a task pointing at a module with no ref (or vice versa) is silently malformed
Indexes:
- PK on
id - on (
tenant_id) - on (
status) WHEREstatus IN ('open','in_progress')— active task queue ('overdue'is computed at query, not stored) - on (
due_date) - on (
assigned_to_user_id) - on (
task_type)
audit.dpa_agreement — 15 cols
Data Processing Agreement (DPA) management and version history. One row per DPA version with a supersedes_dpa_id chain. Covers Vrida–tenant DPA, tenant–customer DPAs, and tenant–subprocessor DPAs.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
counterparty_name |
text | NOT NULL | — | Name of the other party to the DPA |
counterparty_type |
text | NOT NULL | — | CHECK IN ('customer','vendor','subprocessor','other') |
version |
text | NOT NULL | — | Version identifier (e.g. 'v1.0', '2026-06') |
status |
text | NOT NULL | 'draft' |
CHECK IN ('draft','active','superseded','terminated') |
effective_date |
date | nullable | — | Date the DPA came into force |
expires_at |
date | nullable | — | Expiry date if time-limited |
document_ref |
text | nullable | — | Files / R2 key for the signed DPA document. NOT CLOSED — Files module not built; text seam. |
supersedes_dpa_id |
UUID | nullable | — | FK → audit.dpa_agreement — the prior version this replaces; forms a version chain |
signed_at |
timestamptz | nullable | — | When signatures were completed |
notes |
text | nullable | — | Internal notes |
Indexes:
- PK on
id - on (
tenant_id) - on (
counterparty_type) - on (
status) WHEREstatus = 'active' - on (
expires_at) - UNIQUE on (
tenant_id,counterparty_name,version) WHEREdeleted_at IS NULL
audit.subprocessor — 13 cols
GDPR subprocessor list — the maintained register of third-party processors the tenant uses. GDPR Article 28 requires tenants to maintain and disclose this list. Regulators ask for it explicitly; it is a real maintained list with its own lifecycle, not a JSONB column on dpa_agreement.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | Subprocessor name (e.g. 'Stripe', 'Resend', 'AWS') |
purpose |
text | NOT NULL | — | What data processing they perform (e.g. 'payment processing', 'transactional email delivery') |
data_categories |
JSONB | nullable | — | Array of data category labels they process (e.g. ["payment_data","customer_pii"]) |
location |
text | nullable | — | Country or region of data processing (data residency context) |
dpa_id |
UUID | nullable | — | FK → audit.dpa_agreement — the DPA governing this subprocessor relationship |
status |
text | NOT NULL | 'active' |
CHECK IN ('active','pending','removed') |
added_at |
timestamptz | NOT NULL | — | When this subprocessor was added to the register |
removed_at |
timestamptz | nullable | — | When removed (status = 'removed') |
Indexes:
- PK on
id - on (
tenant_id) - on (
status) WHEREstatus = 'active' - UNIQUE on (
tenant_id,name) WHEREdeleted_at IS NULL
audit.audit_export_job — 16 cols
Tracks tamper-evident signed exports of audit log data — scheduled weekly exports, on-demand exports, SAR data packages, and regulator-bound export bundles. The signed hash (feature 1.8 note) provides tamper-evidence for the exported file.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
export_type |
text | NOT NULL | — | CHECK IN ('scheduled','on_demand','sar_response','regulator') |
scope |
text | nullable | — | Human-readable description of what was exported (date range, filters applied) |
format |
text | NOT NULL | 'csv' |
CHECK IN ('csv','pdf','json') |
status |
text | NOT NULL | 'pending' |
CHECK IN ('pending','running','completed','failed') |
export_ref |
text | nullable | — | Files / R2 key for the completed export file. NOT CLOSED — Files module not built; text seam. |
signature |
text | nullable | — | SHA-256 signed hash of the export content — provides tamper-evidence for the exported file |
row_count |
integer | nullable | — | Number of audit log rows included in the export |
requested_by_user_id |
UUID | nullable | — | FK → identity.identity_user; NULL for scheduled/automated exports |
started_at |
timestamptz | nullable | — | When export generation began |
completed_at |
timestamptz | nullable | — | When the export file was finalized |
failure_reason |
text | nullable | — | Error message if status = 'failed' |
Indexes:
- PK on
id - on (
tenant_id) - on (
status) - on (
export_type) - on (
completed_at)
Column counts: audit_log(22) + data_subject_request(17) + data_breach_incident(20) + compliance_task(15) + dpa_agreement(15) + subprocessor(13) + audit_export_job(16) = 118