canopy-persons Data Model
On this page
Cross-link: canopy-persons API Reference · Source: migrations/
Tables
| Table | Purpose |
|---|---|
|
Individual master record. One row per natural person known to the system. Carries demographics (name, gender, race[], ethnicity), |
|
Household identity row. Effective-dated ( |
|
DROPPED in T2-1 CONTRACT (#890) — superseded by the valid-time |
|
DROPPED in T2-1 CONTRACT (#890) — superseded by the valid-time |
|
DROPPED in T1-4 Slice 3 (#672) — superseded by the valid-time |
|
Per-service transactional outbox (ADR-018), schema single-sourced in |
|
Per-service consumer inbox (#433 / ADR-018 amendment). |
|
Per-service crypto-shred key store (migration |
|
Generation gate for the applicant-finalize saga. One row per |
|
Transactional idempotency receipt for the applicant-finalize saga — the exactly-once mechanism at the persons layer (NOT the generic idempotency middleware, which is at-least-once-on-crash and caches plaintext PII). One row per finalize step, written in the SAME tx as the entity it creates + the (held) outbox event it stages. Columns |
Cross-service FKs (ADR-001 boundary)
canopy-persons is at the bottom of the ADR-001 dependency graph: every program service holds application-level FKs into canopy-persons (household_id, person_id), but canopy-persons holds no FKs out. The DB-level REFERENCES constraints in this schema are all intra-database: each of {income,asset,expense,address}_versions.person_id references persons, and household_member_versions references both persons (person_id) and households (household_id). There are no cross-service Postgres-level FKs. Caller services pass person_id / household_id over HTTP and trust canopy-persons to resolve them.
Retention
canopy-persons holds PII (names, DOBs, encrypted SSNs, addresses) but no FTI and no PHI claims data. No specific federal retention ceiling applies — retention is operator-driven and indefinite by default. The ssn_encrypted column uses canopy-common::crypto with the rotation discipline described in ADR-017 (encrypted-secrets-at-rest); rotation re-encrypts ciphertext without ever materialising plaintext outside the per-request decryption boundary. Soft-deletion (active = false) preserves rows for audit and for late-arriving cross-program references; hard deletion is operator-driven and rare.
Indexes
-
idx_persons_name—(last_name, first_name)btree; serves prefix/sort, but NOT the palette’s infixILIKE '%q%'search (a btree cannot) -
idx_persons_first_name_trgm/idx_persons_last_name_trgm(GINgin_trgm_ops, partialWHERE active = true, #1209 scale audit H4) — make the infixILIKEname search index-driven instead of a full-table scan per command-palette keystroke (requires thepg_trgmextension; engages fully at ≥3 characters) -
idx_persons_active(partial,WHERE active = true) — default listing -
idx_{income,asset,expense}_versions_person_current(partial,WHERE superseded_at IS NULL) — the as-of read hot path on the version corpus (the legacyidx_{income,assets,expenses}_personindexes were dropped with their tables in T1-4 Slice 3) -
idx_address_versions_fact(on(fact_id)) +idx_address_versions_person_current(partial,WHERE superseded_at IS NULL, on(person_id)) — the read hot paths onaddress_versions(T2-1 A1; the legacyidx_addresses_personindex was dropped with theaddressestable in the T2-1 CONTRACT, #890) -
idx_household_member_versions_fact(on(fact_id)),idx_household_member_versions_person_current(partial,WHERE superseded_at IS NULL, on(person_id)— the as-of-aware person→household JOIN), andidx_household_member_versions_household_current(partial,WHERE superseded_at IS NULL, on(household_id)— the as-of household read) — the read hot paths onhousehold_member_versions(T2-1 A2; the legacyidx_household_members_*+idx_unique_household_memberindexes were dropped with thehousehold_memberstable in the T2-1 CONTRACT, #890) -
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, on(enqueued_at)) — replay / janitor hot path
Migration files
-
20260326000000_create_persons_tables.sql— original 7-table schema (persons, households, household_members, addresses, income, assets, expenses) with per-FK indexes -
20260402000000_add_constraints.sql— non-negative CHECK constraints on income/assets/expenses amounts; unique partial index preventing duplicate active household members -
20260406000000_add_disability_status.sql— adds nullabledisability_statusTEXT column to persons -
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) -
20260604000000_create_fact_version_tables.sql— the{income,asset,expense}_versionsvalid-time tables + btree_gist non-overlap EXCLUDE + one-time backfill (T1-3 / #671 — the expand-contract EXPAND step) -
20260618000000_drop_legacy_fact_tables.sql— drops the legacyincome/assets/expensestables + thebackfill_fact_versions_v1()function (T1-4 Slice 3 / #672 — the CONTRACT step, once the version corpus is the sole read+write store; function-before-table for the SQL dependency) -
20260623000000_create_address_versions.sql— theaddress_versionsvalid-time table + btree_gist non-overlap EXCLUDE + the idempotentbackfill_address_versions_v1()(T2-1 A1 / #683 — EXPAND; the legacyaddressestable kept frozen, dropped in the CONTRACT step below) -
20260624000000_create_household_member_versions.sql— thehousehold_member_versionsvalid-time table + btree_gist non-overlap EXCLUDE over(household_id, person_id, daterange)+ the idempotentbackfill_household_member_versions_v1()(T2-1 A2 / #683 — EXPAND; the legacyhousehold_memberstable kept frozen, dropped in the CONTRACT step below) -
20260625000000_drop_legacy_address_household_member_tables.sql— drops the legacyaddresses+household_memberstables + thebackfill_address_versions_v1()/backfill_household_member_versions_v1()functions (T2-1 CONTRACT / #890 — the contract half of the A1/A2 EXPAND, once the version corpus is the sole store; function-before-table for the SQL dependency) -
20260714000000_create_finalize_operation_generations.sql— ADR-038 finalize generation gate (finalize_operation_generations) — theFOR SHARElate-write-after-compensation guard (epic &71 / #1047) -
20260714000001_create_finalize_receipts.sql— ADR-038 finalize transactional receipt (finalize_receipts, FK →finalize_operation_generations) — the exactly-once mechanism at the persons layer (epic &71 / #1047) -
20260819000000_persons_name_trgm_gin.sql— #1209 (scale audit H4): thepg_trgmextension + partial GIN trigram indexes onfirst_name/last_name(WHERE active = true), making the infix name search index-driven; transactional (NOT CONCURRENTLY, same #1196 rationale), forward-only,IF NOT EXISTS
All migrations are forward-only per ADR-016.
Fact version tables (ADR-027 / epic &56)
income_versions / asset_versions / expense_versions (migration 20260604000000, T1-3 / #671) hold the valid-time, append-only, attributed version history of the time-varying facts. Each row carries version_id (PK), fact_id (the stable cross-service handle — ADR-025; equals the legacy row id for backfilled facts), person_id, the half-open valid-time window [valid_from, valid_to), the transaction-time recorded_at + superseded_at (a correction marks the old record-version superseded; valid-time is immutable history), the attribution columns (author_type/author_id/claim_source/claim_status/origin/proposed_value JSONB), and the per-fact value columns. Since T2-6 MR8 (#687, ADR-036) the PII value columns are crypto-shred-sealed — income amount/employer_name, asset value/description, expense amount, address line_1/line_2 are now JSONB holding a SealedValue envelope under one per-fact DEK (subject_kind <fact>_version, subject_id = fact_id, wrapped in redaction_keys); the former CHECK (amount/value >= 0) constraints were dropped (the invariant moved to the application layer pre-seal — they are meaningless on ciphertext), and the structural discriminators (income_type/asset_type/expense_type, frequency, address_type/city/state/zip/county_fips) stay plaintext. Because the per-fact DEK keys on fact_id, redacting a fact (POST …/facts/{kind}/{fact_id}/redact) shreds every version’s value in one tombstone, and a correction remnant re-tile copies the sealed envelope verbatim. A non-deferrable EXCLUDE USING gist (fact_id WITH =, daterange(valid_from, valid_to, '[)') WITH &&) WHERE (superseded_at IS NULL AND claim_status LIKE 'accepted%') enforces that no two current accepted versions of a fact overlap (it keys only on fact_id + daterange, so sealing the value columns leaves it untouched).
Write path (T1-4 Slice 2 / #672): the POST /v1/persons/{id}/{income,assets,expenses}/claims endpoints append via the correction algorithm (lock → supersede-overlapping → re-tile-remnants → insert), filtered by person_id AND fact_id (ADR-027 ownership); income remove is a CLOSE (DELETE …/income/claims/{fact_id} → supersede + left-remnant-only).
Read path (T1-4 Slice 3 / #672): this corpus is the sole fact store. Every fact read (per-person GET …/{income,assets,expenses}, GET /v1/households/{id}/full?as_of, :batchGet) resolves the current-accepted version valid on as_of per fact, claim_status-filtered to determination-feeding (Proposed/Rejected excluded) and mapped to the DTO with id = fact_id + provenance. The legacy income/assets/expenses tables + the backfill_fact_versions_v1() function are dropped (20260618000000, the expand-contract CONTRACT step); the seed writes this corpus directly.
Addresses (T2-1 A1 / #683): address_versions (migration 20260623000000) brings the same valid-time shape to addresses — value columns address_type/line_1/line_2/city/state/zip/county_fips, no numeric CHECK, the same non-overlap GiST EXCLUDE + fact/person_current indexes + backfill_address_versions_v1(). The write surface flips from the flat POST …/addresses to POST/DELETE /v1/persons/{id}/addresses/claims (claim + close); GET …/addresses is an as-of-today read, while the bulk export reads all current windows (the FOIA projection dedups identical coarse rows; portability emits one row per live window). Attributed address.claimed/address.closed events carry a coarse, street-redacted value (address_type/city/state/zip/county_fips — never line_1/line_2; the full street persists only in address_versions, per ADR-004 / ADR-027 §8). Like income/assets/expenses before it, the legacy addresses table was kept frozen in A1 (no Rust read it; the backfill_address_versions_v1() function stayed so the backfill transformation was testable) and then dropped in the T2-1 CONTRACT step (20260625000000, #890), along with the backfill function. The deterministic read order is valid_from DESC, recorded_at DESC, fact_id (canopy-web takes the first row, so a stable order matters). Since E1a (#892) address_type is a native PostgreSQL enum (residential / mailing; migration 20260630120000 converts the former free-text TEXT column) — the sqlx-free wire AddressType contract is the source of truth and the store keeps an sqlx::Type mirror — so the case-detail "primary address" pick prefers the first residential over an order-arbitrary one rather than reasoning over ad-hoc strings.
Household membership (T2-1 A2 / #683): household_member_versions (migration 20260624000000) brings the same valid-time shape to household membership — version_id (PK), fact_id (the stable cross-service handle = the legacy household_members.id), person_id (FK persons), household_id (FK households), the half-open [valid_from, valid_to) window, transaction-time recorded_at + superseded_at, the attribution columns (author_type/author_id/claim_source/claim_status/origin/proposed_value JSONB), and the value column relationship. Membership is household-scoped (the ownership + correction key is household_id, not person_id), so — unlike income/assets/expenses/addresses, whose non-overlap is per-fact_id — the non-overlap GiST EXCLUDE USING gist (household_id WITH =, person_id WITH =, daterange(valid_from, valid_to, '[)') WITH &&) WHERE (superseded_at IS NULL AND claim_status LIKE 'accepted%') is per-(household_id, person_id): it is the valid-time form of the legacy UNIQUE (household_id, person_id) WHERE active = true, preserving the "no duplicate active membership per household" invariant (a person may be in two different households at once, never the same one twice; btree_gist services both UUID equality columns). The household_member_versions_valid_range CHECK (valid_to IS NULL OR valid_to > valid_from) guards the window; there is no numeric CHECK (relationship is not numeric). Three indexes back the read paths: (fact_id), partial (person_id) WHERE superseded_at IS NULL (the as-of-aware person→household JOIN), and partial (household_id) WHERE superseded_at IS NULL (the as-of household read); the deterministic read order is fact_id (UUID v7 = stable creation order that survives corrections). The write surface flips from the flat POST /v1/households/{id}/members (AddMember) + DELETE …/members/{member_id} to the authored POST/DELETE /v1/households/{id}/members/claims[/{fact_id}] (claim + close); GET /v1/households/{id} and /full are now as-of valid-time member reads, and because membership is denormalised onto every person read as Person.household_id, the person→household JOIN is now as-of-aware (it reads the version corpus valid on the read’s as_of, so Person.household_id stays consistent with the as-of household reads). The attributed household.member_claimed/household.member_closed events carry the full MemberFactValue (relationship is not PII, so — unlike the street-redacted address events — no coarse projection is applied), plus household_id and the before/after windows. The idempotent backfill_household_member_versions_v1() backfills each legacy household_members row as a system-authored v1 (active → open window; a removed/active=false row → a window closed at end_date or, absent that, the row’s updated_at). Like income/assets/expenses before it, the legacy household_members table was kept frozen in A2 (no Rust read it; the backfill function stayed so the backfill transformation was testable) and then dropped in the T2-1 CONTRACT step (20260625000000, #890), along with the backfill function (mirroring A1’s).