canopy-notices Data Model
On this page
Cross-link: canopy-notices API Reference · Source: migrations/
Tables
| Table | Purpose |
|---|---|
|
One row per generated notice. PDF is the canonical output; the schema deliberately omits |
|
The event-routed generation queue: the subscriber persists one row per |
|
Per-notice appeal-rights companion under 7 CFR 273.13(a)(3). One row per notice that carries adverse-action consequences. Carries |
|
Per-service transactional outbox (ADR-018), schema single-sourced in |
|
Per-service consumer inbox (#433 / ADR-018 amendment). |
Cross-service FKs (ADR-001 boundary)
Per ADR-001, canopy-notices holds no Postgres-level foreign keys to other services. The columns marked FK → canopy-persons / FK → canopy-applications / FK → program service above are application-level foreign keys: canopy-notices trusts the upstream services (canopy-eligibility orchestrator + the program services) to supply real IDs but does not enforce existence in canopy_notices. Cross-service IDs the service holds are household_id, recipient_person_id, application_id, and determination_id.
Address-confidentiality (ACP) mail routing (#1146)
Before anything renders, the work-item worker reads the household’s
confidentiality election from canopy-applications (newest application,
GET /v1/applications?household_id=…&limit=1) and fails closed: an
unavailable signal is a retryable work-item error, never a
mail-the-street-address fallback. An address_confidential/both election
swaps the resolved postal block for the jurisdiction’s Address
Confidentiality Program substitute address ([notices.acp] in
jurisdiction.toml), keeping the participant’s name — ACP mail is forwarded
by the program. Case-confidential-only elections do not reroute mail (they
gate disclosure, not routing).
Design note (the #1146 fork, recorded per its acceptance criteria). The
substitute address lives in jurisdiction config, not a canopy-persons
fact. Rationale: the election itself is application-level today (#1137
wizard → applications.confidentiality), notices already boot-loads
jurisdiction.toml, and the substitute address is program-wide policy data,
not per-person PII. The trade-off: a real ACP is a per-person enrollment
(authorization number, effective/expiry dates, possibly participant-specific
addressing) — that production-grade model is tracked as #1186 and would
supersede the config route per participant when it lands. Georgia’s ruleset
deliberately omits [notices.acp] until the SME-confirmed operational
address lands (#1185); until then the documented interim rule applies — mail
goes to the household’s own chosen address (where they live, so not a
third-party disclosure) and the worker warns loudly on every such item. The
default ruleset ships a clearly-synthetic demo block so the routing path is
exercised end-to-end. The on-demand PDF fallback path never emits a street
address (placeholder recipient block), so it needs no gate. Known residual
surface: the applicant portal’s Letters passthrough streams stored PDFs
whose recipient blocks may predate ACP routing — tracked as #1188.
Retention
Rendered PDF copies must be retrievable for the duration of the case + appeal window. The PDFs themselves are not stored in the database — they live in the S3-compatible object store (Garage in devstack, S3-API-compatible in production) at the pdf_storage_path key. The DB stores notice metadata + the S3 pointer; lifecycle and retention of the PDF objects is operator-driven on the bucket side. DB rows are retained for at least 3 years from notice_date per 7 CFR 274.6 (case records) and for the life of any open appeal referencing the notice (notice_id is held by canopy-appeals.appeal_requests). Rows are not deleted in production.
Indexes
-
idx_notices_household— household-scoped notice lookup -
idx_notices_recipient— recipient-scoped notice lookup -
idx_notices_type— list-by-type (worker portal filters) -
idx_notices_program— program-scoped notice list -
idx_notices_delivery(partial,WHERE active = true) — delivery-status work queue -
idx_notices_active_created_at_id(partial,WHERE active = true,(created_at DESC, id DESC)) — theGET /v1/noticeskeyset page cursor (#1214); serves the newest-first list as an index scan with no top-N sort -
idx_notices_date— notice-date scan for federal reporting / aging queries -
idx_notices_application— notices-by-application lookup -
idx_notices_determination— notices-by-determination lookup (appeal cross-link) -
idx_notice_work_items_due(partial,WHERE status = 'pending') — the worker’s claim scan (#1091) -
idx_notice_appeal_rights_notice— appeal-rights companion lookup -
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
-
20260401000000_create_notices_tables.sql— original schema (notices, notice_appeal_rights; lookup + delivery indexes); deliberately omits body_text / body_html so PDF stays canonical -
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 -
20260515000000_add_notice_content_integrity.sql— #435 Pub 1075 §9 integrity columns (content_sha256,content_type,scan_status); forward-only with DEFAULTs so pre-migration rows survive -
20260516000000_create_event_inbox.sql— #433 consumer-side inbox (ADR-018 amendment) -
20260608000000_add_notice_read_tracking.sql— #721 read/unread: addsnotices.read_at(nullable, no backfill); forward-only additive per ADR-016 -
20260720000000_event_inbox_parking.sql— #1089 parked-state columns +idx_event_inbox_parked(generated, single-sourced with the outbox; see the event-delivery protocol) -
20260721000000_notice_work_items.sql— #1091 work-item queue (notice_work_items+idx_notice_work_items_due) andnotices.dispatched_at -
20260727000000_action_notice_binding.sql— #1101 (epic &72 MR 3.1): the adverse-action binding columns + the Chart B2cb_election_deadline+ the per-action evidence index -
20260816000000_notices_list_keyset_idx.sql— #1214: theidx_notices_active_created_at_idpartial index(created_at DESC, id DESC) WHERE active = truebacking theGET /v1/noticeskeyset page cursor; transactionalCREATE INDEX(notCONCURRENTLY— the sqlx migrator’s advisory lock deadlocks against `CONCURRENTLY’s snapshot wait) -
20260901000000_notices_dispatch_claims.sql— #1216 (scale audit M4): the dispatch claim-lease + bounded-retry ledger (dispatch_claimed_at/dispatch_claimed_by/dispatch_attempts/dispatch_next_attempt_at/dispatch_last_error) andidx_notices_dispatch_dueover the dispatch-due predicate -
20261105000000_notices_acp_applied.sql— #1188 recipient-block provenance: addsnotices.acp_applied(BOOLEAN NOT NULL DEFAULT FALSE); gates the applicant portal’s letter-PDF streaming for address-confidential households
All migrations are forward-only per ADR-016.