canopy-notices Data Model

On this page

Cross-link: canopy-notices API Reference · Source: migrations/

Tables

Table Purpose

notices

One row per generated notice. PDF is the canonical output; the schema deliberately omits body_text / body_html and stores only the S3 pointer + metadata. Carries the recipient pair (household_id, recipient_person_id), the notice_type (drives template selection), optional program / application_id / determination_id context, the subject line, the templating pair (template_key, template_version) used for replay, the optional federal/state form_number, the locale (DEFAULT 'en-US'), the regulatory_basis (CFR / PAMMS cite carried for audit), date fields (effective_date, notice_date, advance_notice_days with advance_notice_adjusted recording whether the 10-day clock under 7 CFR 273.13 was extended for cause), and the storage sextet (pdf_storage_path — S3 key in the Garage / S3 bucket — plus pdf_size_bytes, page_count, and the #435 Pub 1075 §9 integrity trio: content_sha256 (SHA-256 of stored PDF bytes), content_type (magic-byte-verified at upload time, DEFAULT 'application/pdf'), scan_status (AV scanner result; reflects the Scanner::name() of the wired backend — 'noop' today, future values when a real scanner lands)). Delivery tracking (#1091, reshaped by #1216): delivery_status (pending → dispatched, or terminally failed after bounded refused attempts; the dispatcher loop’s 'pending' fence makes the stamp race-safe), dispatched_at (provider ACCEPTED the notice for delivery — the epic &72 dispatch evidence), delivered_at (confirmed receipt; stays NULL until a receipt-capable production carrier exists — a tracked &72 production gap), and delivery_channel (DEFAULT 'test'). The #1216 dispatch ledger — dispatch_claimed_at / dispatch_claimed_by (the FOR UPDATE SKIP LOCKED lease that partitions the queue across replicas, the duplicate-physical-mail guard), dispatch_attempts (counted at claim), dispatch_next_attempt_at (exponential retry gate doubling from 60s, 30s · 2^attempts on the post-claim count, capped ~2.1h; NULLed on terminal park), dispatch_last_error (provider refusal, truncated to 1024 chars) — makes dispatch leased bounded-retry; an operator resend (delivery_status → 'pending') resets the ledger. idx_notices_dispatch_due (created_at, id) partial over the dispatch-due predicate serves the claim scan. Read tracking (#721): read_at (nullable; first-read timestamp set when the recipient opens the notice in the applicant-portal Letters inbox — NULL means unread; mark-read is idempotent via COALESCE(read_at, now()) so the original read time survives repeat opens). Recipient-block provenance (#1188): acp_applied (BOOLEAN NOT NULL DEFAULT FALSE; TRUE iff the letter’s postal block was rendered with the jurisdiction’s [notices.acp] substitute address per the #1146 AcpOutcome::Applied swap, stamped by the render worker — the applicant portal suppresses inline PDF streaming for address_confidential/both households unless this is TRUE; DEFAULT FALSE is the fail-closed backfill: pre-#1188 letters have no provenance and are all treated as real-address — over-suppression is the safe direction). Since #1101 (epic &72 MR 3.1) an action-bound notice also persists its adverse-action binding — adverse_action_id (the spine id), adverse_action_generation (source provenance), action_reason_code, exemption_authority — and the legal cb_election_deadline (Chart B2: notice_date + continued_benefits_election_days, INCLUSIVE, the same [appeals] window canopy-appeals enforces).

notice_work_items (#1091)

The event-routed generation queue: the subscriber persists one row per (source_event_id, notice_type) (UNIQUE — a redelivered/replayed event upserts into the same item, never a second notice) atomically with its inbox row, carrying the full typed GenerateNoticeRequest as JSONB request. The worker loop claims items via single-statement FOR UPDATE SKIP LOCKED + lease (claimed_at/claimed_by — also the completion FENCE: complete/fail match only the exact claim, so a lease stolen mid-render can never double-mint), performs recipient resolution + render + scan + upload OUTSIDE any transaction, then commits the fence flip + notice rows in one short transaction. status (pending → done / failed CHECK), attempts, last_error, due_at (exponential backoff, 30s·2^attempts capped 1h; terminal failed at 8 attempts), notice_id back-pointer once done.

notice_appeal_rights

Per-notice appeal-rights companion under 7 CFR 273.13(a)(3). One row per notice that carries adverse-action consequences. Carries hearing_request_deadline (the calendar date by which the household must request a hearing to preserve continued-benefits eligibility per 7 CFR 273.15(k)), continued_benefits_available + optional continued_benefits_request_deadline, and the contact strip (hearing_phone, optional hearing_address) rendered into the notice PDF and surfaced via the appeal-rights API.

event_outbox (ADR-018 + ADR-039)

Per-service transactional outbox (ADR-018), schema single-sourced in crates/canopy-mq/outbox-migrations/ and generated into this service (ADR-039). Columns, indexes, hold semantics, and the migration inventory are documented ONCE in the cross-cutting description — see the data-models index.

event_inbox (#433)

Per-service consumer inbox (#433 / ADR-018 amendment). Subscriber writes a row before invoking the handler; PK on event_id (the envelope’s UUID v7) makes RabbitMQ redelivery idempotent. Carries (event_id, routing_key, payload, enqueued_at, processed_at, attempts, last_error) plus the #1089 parked-state columns (parked_at, park_reason, park_min_schema, queue_name — see the event-delivery protocol). Janitor (canopy-mq::InboxDrainer) sweeps processed rows older than 7 days.

Relationships

Diagram

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)) — the GET /v1/notices keyset 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: adds notices.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) and notices.dispatched_at

  • 20260727000000_action_notice_binding.sql — #1101 (epic &72 MR 3.1): the adverse-action binding columns + the Chart B2 cb_election_deadline + the per-action evidence index

  • 20260816000000_notices_list_keyset_idx.sql — #1214: the idx_notices_active_created_at_id partial index (created_at DESC, id DESC) WHERE active = true backing the GET /v1/notices keyset page cursor; transactional CREATE INDEX (not CONCURRENTLY — 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) and idx_notices_dispatch_due over the dispatch-due predicate

  • 20261105000000_notices_acp_applied.sql — #1188 recipient-block provenance: adds notices.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.

Edit this page · default