canopy-security Data Model
On this page
Cross-link: canopy-security API Reference · Source: migrations/
Tables
| Table | Purpose |
|---|---|
|
The system-wide audit log. One row per domain event received from the wildcard ( |
|
Long-term archive of aged-out audit rows. Originally created via |
|
Detection-rule firings. One row per breach detected by the background detector. Carries |
|
NIST SP 800-53 control coverage table, seeded by 20260326000001. One row per control with |
|
Configurable breach detection rules (IRS Pub 1075 §9), seeded by 20260326000002 and REPAIRED by 20261102000000 (#1302 — the shipped detector was double-inert: dispatch handled only |
|
Per-FTI-service hash-chain verification result ledger per ADR-014 §7-8. One row per verify run for canopy-tanf or canopy-medicaid. Carries |
|
Per-service transactional outbox (ADR-018), schema single-sourced in |
|
Per-service consumer inbox (#433 / ADR-018 amendment). |
|
The durable archive-run record — the unit of async archival work AND the accountability record. UUIDv7 PK; |
|
The transactional due-state singleton for scheduled archival (Skip semantics). One row ( |
Cross-service FKs (ADR-001 boundary)
canopy-security holds no Postgres-level foreign keys to other services. The cross-service identifiers it stores are all opaque text/UUID:
-
audit_events.event_id,audit_events.user_id,audit_events.resource_id,audit_events.source_service— supplied by the publishing service; treated as text/UUID lookups, never JOINed -
audit_events.metadata— JSONB envelope from the publisher; may contain further service-scoped IDs -
fti_chain_verifications.broken_at_row_id— points intofti_audit_login canopy-tanf or canopy-medicaid; resolved by the auditor via the program service’s API, not by a JOIN
Per ADR-004, FTI never lands in canopy-security itself. The FTI tables (fti_audit_log, fti_audit_log_archive) live in canopy-tanf and canopy-medicaid; canopy-security only persists wildcard-subscribed audit metadata about FTI access.
Retention
Wildcard event-subscriber persistence with append-only guarantees. The FTI audit hash chain extends across audit_events ↔ audit_events_archive, so the archive is part of the chain — not a "delete" target. Per ADR-014 chain-integrity guarantees, rows are append-only and never deleted; the archive mover (#1208) only moves aged rows between the two tables. The knob is an age threshold (archive_after_days), not a retention value: it is mechanical, operator-set, and deliberately has no default — rows whose received_at is older than the threshold move live→archive, nothing more. Retention is archive ∪ live, and audit_events_archive retains rows indefinitely, so archiving early shortens nothing; the by-id, FOIA-export, and fact-history reads span both tables. Retention policy — floors, legal hold, purge, per-family windows — is #1303, not embedded here. NIST SP 800-53 AU-11 ("Audit Record Retention") is satisfied by the union of the two tables; the federal floors it must clear are 3 years for SNAP records per 7 CFR 272.1(f), 3 years from final claim for Medicaid per 42 CFR 433.32, and 7 years for IRS Pub 1075 FTI access records. breach_alerts and fti_chain_verifications are retained indefinitely for compliance evidence; the seed nist_control_mappings and detection_rules are configuration-as-data.
Indexes
-
idx_audit_events_event_type— per-routing-key listing -
idx_audit_events_source_service— per-service listing -
idx_audit_events_action— per-action listing -
idx_audit_events_user_id— per-user audit trail -
idx_audit_events_resource_type— per-resource-type listing -
idx_audit_events_event_timestamp— chronological listing -
idx_audit_events_metadata(GIN) — JSONB metadata search -
idx_audit_events_event_hash— hash-chain verification lookups (added by 20260402000001) -
idx_audit_events_created_at_id—(created_at DESC, id DESC)for the in-lock predecessor-hash lookup on every insert ANDverify_chain’s ascending walk (a btree scans both directions); #1197, migration 20260811000000. Without it the top-1 lookup held under `pg_advisory_xact_lock(1)is an O(table) seq-scan + sort, decaying chain-append throughput as the log grows (1–5M rows/day at GA scale) -
audit_events_household_idx(partial,WHERE household_id IS NOT NULL) — worker-portal Audit-section household filter (added by 20260601000010) -
audit_events_programs_gin(partial GIN,WHERE programs IS NOT NULL) — theprograms && $scopeoverlap arm of the #1519 visibility predicate; NULL rows are excluded from scoped reads by definition so the partial index matches the only rows the operator can return (added by 20261128000000) -
idx_audit_events_received_at_id—(received_at, id)on the live table: the archive mover’s candidate scan (#1208, migration 20261101000000) -
idx_audit_events_archive_received_at_id—(received_at, id)on the archive twin: the keysetGET /v1/security/archivelist (received_at DESC, id DESCrides the same btree backward) (#1208) -
idx_audit_events_archive_event_timestamp_id—(event_timestamp, id)on the archive twin: the FOIA-export union arm (#1208) -
idx_audit_events_archive_persons_metadata— partial GIN onmetadataWHERE source_service = 'canopy-persons': the fact-history union arm, matching the query’s constant predicate exactly (#1208 P3) -
audit_archive_runs_active_uq(unique, partial,true WHERE state IN ('queued','running')) — ONE active archive run total (#1208) -
audit_archive_runs_queued_idx/audit_archive_runs_reclaim_idx(partial) — queued-claim and expired-lease-reclaim scans (#1208) -
idx_breach_alerts_rule_name— per-rule alert history -
idx_breach_alerts_status— open-alert listing -
idx_breach_alerts_severity— severity triage -
idx_nist_control_mappings_control_id— per-control lookup -
idx_unique_nist_control(unique, oncontrol_id) — prevents duplicate seedings (added by 20260402) -
idx_detection_rules_rule_type— per-type listing -
idx_fti_chain_verifications_service_verified_at—(service, verified_at DESC)for latest-per-service auditor endpoint -
event_outbox_*— the three generated partial outbox indexes (drainer hot path, lease-aware claim, ADR-039 held-skip); documented once in the data-models index -
event_inbox_unprocessed_idx(partial,WHERE processed_at IS NULL) — replay / janitor hot path
Migration files
-
20260326000000_create_security_tables.sql— original schema: audit_events, breach_alerts, nist_control_mappings, detection_rules, audit_events_archive (viaLIKE audit_events INCLUDING ALL); per-column indexes -
20260326000001_seed_nist_controls.sql— seeds 11 NIST SP 800-53 control mappings (AU-2/3/6/9/11, AC-2/6/7, SI-4, IR-4/5) -
20260326000002_seed_detection_rules.sql— seeds 4 default detection rules (failed auth, bulk access, privilege escalation, after-hours) -
20260402000000_add_constraints.sql— CHECK constraints on breach_alerts.status and detection_rules.severity; unique index on nist_control_mappings.control_id -
20260402000001_add_hash_chain.sql— addsprevious_hash+event_hashcolumns toaudit_eventsper ADR-014; index onevent_hashfor verification lookups -
20260409000000_align_archive_hash_columns.sql— back-fills the same hash columns ontoaudit_events_archive(the originalLIKEpredated the hash-chain migration) so the archive insertion query succeeds and the chain remains verifiable across the archive boundary -
20260425000001_create_fti_chain_verifications.sql— per-FTI-service hash-chain verification ledger per ADR-014 §7-8 -
20260508000000_create_event_outbox.sql+20260518004851_event_outbox_lease_columns.sql+20260713000000_event_outbox_hold.sql— the generated ADR-039 single-sourced outbox migrations (cargo xtask outbox-migrations --write); documented once in the data-models index -
20260516000000_create_event_inbox.sql— #433 consumer-side inbox (ADR-018 amendment) -
20260601000010_add_household_id_to_audit_events.sql— adds nullablehousehold_id UUIDtoaudit_eventsandaudit_events_archive+ the partial indexaudit_events_household_idx(Plan-1 worker-intake MR5a, #621) -
20260811000000_audit_events_created_at_id_idx.sql—idx_audit_events_created_at_idfor the in-lock predecessor lookup + verify walk (#1197, scale audit C4/C5). Transactional (NOTCONCURRENTLY) for the same reason as the #1196 eligibility index migration — the sqlx-migrator advisory lock deadlocks withCONCURRENTLY’s snapshot wait (reproduced 40P01); operators pre-create it `CONCURRENTLYout of band on huge live tables and this no-ops viaIF NOT EXISTS -
20260910000000_chain_v2_substrate.sql— #1246 MR-2 (ADR-014 Amendment 6): the dormant chain-v2 substrate — see the chain-v2 section below -
20260930000000_chain_append_staging.sql— #1207 (ADR-014 Amendment 7): the durable append-transport staging queue — see the chain-v2 append staging section below -
20261010000000_chain_verification_hardening.sql— #1205/#1206 MR-1 (ADR-014 Amendment 9): the C6 hardening reshape — see the chain-v2 verification hardening section below -
20261015000000_chain_verification_projections.sql— #1205 MR-2 (ADR-014 Amendment 9): the_appprojections + the durable verify-job store — see the chain-v2 verification projections section below -
20261101000000_audit_archive_runs.sql— #1208: the async audit-archival substrate —audit_archive_runs+audit_archive_schedule(rows above) + the four mover/read-path indexes, followed by a definition-verification DO block (RAISEs at boot unless each index isindisvalid AND indisready, on the right table, and definition-matched —IF NOT EXISTSchecks only the name, so a wrong or INVALID same-named index from a failed out-of-bandCONCURRENTLYbuild fails the boot loudly). Transactional (NOTCONCURRENTLY) per the 20260811000000 precedent; operators pre-create the indexesCONCURRENTLYout of band on huge live tables (Security Operations › Archive Management) and the `CREATE`s here no-op -
20261128000000_add_programs_to_audit_events.sql— #1519 (ADR-044 epic &78 MR-5): nullableprograms TEXT[]onaudit_events+audit_events_archive(lock-step with the mover’s explicitAUDIT_EVENT_COLUMNSlist;dedup_keystays live-only by design, #1424) + the partial GINaudit_events_programs_gin. Forward-only, NO backfill — a synthesized program for a historical row would be an unverifiable authorization statement; the header records the outside-the-frozen-v1-hash posture (dedup_keyprecedent) and defers chain-v2 treatment to #1279
All migrations are forward-only per ADR-016.
chain-v2 substrate (dormant, #1246 / ADR-014 Amendment 6)
Installed by 20260910000000_chain_v2_substrate.sql — the SUPERSET copy
(canopy_security is the anchor-emitting authority). Everything is dormant
until the #1279 cutover: tables empty, roles NOLOGIN, v1 writers untouched.
| Table | Purpose |
|---|---|
|
The C1/C3/C4 registry: immutable instance history, the active family pointer (composite FK to epochs), fenced epoch states ( |
|
The audit family’s strict-from-row-one event store: BYTEA(32) hash CHECKs, RFC 8785-bounded |
|
The C5 anchor store (all families): canonical manifest bytes + hash, structural monotonic sequencing via the |
|
C6 verification state: lease/fence CAS checkpoints, INSERT-only run records, and incident latch/resolve with SPLIT grants (resolution only via |
Ownership: every object is owned by NOLOGIN canopy_chain_owner_security;
runtime identities get no direct DML anywhere (C8). Functions, grants, and
the append validation rules: ADR-014
Amendment 6 + the migration file itself.
chain-v2 append staging (dormant, #1207 / ADR-014 Amendment 7)
Installed by 20260930000000_chain_append_staging.sql. The durable ingress
queue between "accepted" (bus ack / HTTP 202) and "chained" — a QUEUE, not
chained data, so ownership stays with the migration identity (never the
chain owner role).
| Object | Purpose |
|---|---|
|
One row per accepted-but-unchained audit event: |
|
The ONLY delete path and the ONLY park path (both SECURITY DEFINER, EXECUTE to |
|
The PERMANENT replay identity (Amendment 7): a UNIQUE expression index on the live chain table’s payload |
Lifecycle: INSERT (idempotent, ON CONFLICT DO NOTHING + digest compare) →
router stamp → dequeue-in-append-tx. No janitor: only PARKED rows persist —
an auditor-visible quarantine cleared exclusively by the operator unpark
runbook (Security Operations). Steady-state
row count is bounded by the admission cap; expect high INSERT/DELETE churn
(autovacuum keeps the partial indexes tight — the stats sampler’s one
aggregate per 30s is bounded by the cap and rides the parked partial index
for ops queries).
chain-v2 verification hardening (dormant, #1205/#1206 MR-1 / ADR-014 Amendment 9)
Installed by 20261010000000_chain_verification_hardening.sql — the two
external design reviews' rework of the dormant C6 substrate (plan
chain-v2 verifiers D2). Pre-cutover
verification state is definitionally scratch, so the migration CLEARS the
three C6 tables before reshaping (forward-only, ADR-016).
| Object | As-built shape |
|---|---|
|
Gains |
|
Gains |
|
Gains |
Guarded fns (old signatures DROPPED, never overloaded) |
|
|
Archive-side attestation index ( |
chain-v2 verification projections + durable verify jobs (dormant, #1205 MR-2)
Installed by 20261015000000_chain_verification_projections.sql (plan D6/D8).
The _app status projections and the durable manual-verify job store — same
dormancy as the rest of chain-v2.
| Object | Purpose |
|---|---|
|
The durable manual-verify job store (X4). Columns: v7-CHECKed |
|
Coverage + freshness for the |
|
Latest-run inputs: |
|
Evidence-free incidents for status + the row banner: position, kind, detected loop, state — never evidence, never resolution text (those remain |
|
The trusted manifest: joins the FAMILY checkpoint rows' |
|
Token-free job polling: everything |
Runs |
|
Guarded job fns (SECURITY DEFINER, owner-transferred, PUBLIC revoked; no raw DML grants to anyone) |
|
Grant posture: canopy_security_app gets SELECT on the five views + EXECUTE
on chain_job_enqueue only — it never touches the C6 bases or the raw jobs
table; every view is owner-transferred to canopy_chain_owner_security.