Plan: Application Intake (canopy-applications)

On this page

Status

Step Description Status

1

Database schema: applications, application_programs, authorized_representatives

Done (2026-03-27)

2

Core application CRUD endpoints

Done (2026-03-27)

3

Expedited service screening logic (SNAP 7-day)

Done (2026-03-27)

4

Event publishing for application lifecycle

Done (2026-03-27)

5

Integration tests with testcontainers-rs

Done (2026-03-27)

Epic: Application Intake
MR: !10
Branch: feature/application-intake

Context

ACA §1413 requires a single, streamlined application covering Medicaid, CHIP, and QHP/APTC, available online, by phone, in person, and by mail. 7 CFR 273.1(f) and 42 CFR 431.635 independently require that agencies accept a single application for SNAP and Medicaid together.

canopy-applications is the entry point for all program applications in Canopy. It does not evaluate eligibility — that is `canopy-eligibility’s job. It receives application submissions, records which programs are requested, screens for expedited service, and forwards application contexts to the eligibility orchestrator.

Workers submit applications on behalf of households via canopy-web. Applicants submit their own applications via canopy-portal. Either path creates the same Application record.

This plan covers the data model and API endpoints for application intake. It does not include the portal UI (canopy-web and canopy-portal plans) or the eligibility determination flow (eligibility-orchestrator plan).

Dependency on persons-household-model

Applications reference a household_id from canopy-persons. Code dependency: none — the application-intake code and schema can be written in parallel with persons-household-model. Runtime dependency: canopy-persons must be operational before integration tests run, because the API validates household_id by calling canopy-persons at submission time (a 404 rejects the application).

Application lifecycle

submitted → processing → [determination complete] → complete
         ↘ withdrawn (household withdraws before determination)
         ↘ abandoned (no household contact, timed out)

Program-level lifecycle (each program in the application):

pending → [determination from orchestrator] → approved | denied | withdrawn

Scope

In scope:

  • Applications table, application_programs table, authorized_representatives table with full schema

  • POST /v1/applications — submit new application with expedited screening

  • GET /v1/applications/{id} — get application with program statuses

  • PUT /v1/applications/{id} — update application while status is submitted or processing

  • DELETE /v1/applications/{id} — soft-delete (withdraw)

  • GET /v1/applications?household_id={id} — list applications for household

  • POST /v1/applications/{id}/interview/waive — record interview waiver with reason

  • POST /v1/applications/{id}/interview/complete — record interview completion

  • POST /v1/applications/{id}/programs/{program}/determination — internal endpoint for orchestrator to record determination result

  • Expedited service screening on application submission (SNAP: income < $150 AND assets < $100 OR combined < rent/utilities OR migrant farmworker)

  • Event publishing: application.submitted, application.status_changed, application.withdrawn, application.expedited_identified

  • Authorized representative management

Out of scope:

  • Eligibility determination — handled by canopy-eligibility

  • Document upload — handled by canopy-store integration in a later plan

  • Portal UI — handled by worker-portal-snap and applicant-portal plans

  • Change reporting — handled by canopy-renewals

  • Renewal applications — a renewal is a new application; the renewal routing happens in canopy-renewals

Design

Database schema

Database: canopy_applications on the shared PostgreSQL instance (port 5432).

CREATE TABLE applications (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    submitted_by UUID NOT NULL,              -- person_id of submitter
    authorized_representative_id UUID,       -- set if submitted by someone other than household
    programs_requested TEXT[] NOT NULL,      -- array of Program enum values
    submission_channel TEXT NOT NULL,        -- 'online', 'phone', 'in_person', 'mail'
    submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),  -- may differ from submitted_at for mail
    status TEXT NOT NULL DEFAULT 'submitted',
    -- 'submitted', 'processing', 'complete', 'withdrawn', 'abandoned'
    interview_required BOOLEAN NOT NULL DEFAULT true,
    interview_completed_at TIMESTAMPTZ,
    interview_waived BOOLEAN NOT NULL DEFAULT false,
    interview_waived_reason TEXT,
    expedited_screened_at TIMESTAMPTZ,
    expedited_eligible BOOLEAN,
    expedited_basis TEXT,                    -- 'low_income_assets', 'income_vs_expenses', 'migrant_farmworker'
    -- NOTE: processing_deadline has moved to application_programs (per-program).
    -- Each program has a different federal timeline. See application_programs table.
    submitted_by_role TEXT NOT NULL DEFAULT 'worker',
    -- 'applicant', 'worker', 'authorized_rep', 'agency_system' — required for audit/appeals
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active BOOLEAN NOT NULL DEFAULT true
);

CREATE INDEX applications_household_idx ON applications (household_id);
CREATE INDEX applications_status_idx ON applications (status) WHERE active = true;

CREATE TABLE application_programs (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL REFERENCES applications(id),
    program TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',  -- 'pending', 'approved', 'denied', 'withdrawn'
    determination_id UUID,
    determination_received_at TIMESTAMPTZ,
    denial_reason_codes TEXT[],
    -- Per-program processing deadline (different programs have different federal timelines)
    -- SNAP: received_at + 30 days (7 days if expedited) per 7 CFR 273.2(g)
    -- TANF: received_at + 30 days per 45 CFR 206.10(a)(3)
    -- Medicaid: received_at + 45 days (90 days if disability determination) per 42 CFR 435.912
    -- CHIP: received_at + 45 days per 42 CFR 457.340(d)
    processing_deadline DATE,
    -- TANF-only: distinguish cash assistance (subject to time limits, WPR, ACF-199) from
    -- non-assistance services (employment prep, transportation, one-time diversion).
    -- Non-assistance is NOT subject to 60-month limit, WPR, or most 42 USC §608 prohibitions.
    -- Also used by SNAP categorical eligibility: TANF non-cash triggers BBCE.
    -- Values: 'assistance', 'non_assistance'. NULL for non-TANF programs.
    tanf_service_type TEXT,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX application_programs_unique ON application_programs (application_id, program) WHERE active = true;

CREATE TABLE authorized_representatives (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    representative_person_id UUID NOT NULL,
    relationship TEXT NOT NULL,  -- 'authorized_rep', 'legal_guardian', 'power_of_attorney', 'agency_rep'
    written_consent_on_file BOOLEAN NOT NULL DEFAULT false,
    effective_date DATE NOT NULL,
    expiration_date DATE,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX auth_reps_household_idx ON authorized_representatives (household_id) WHERE active = true;

Request/Response types

// SPDX-License-Identifier: AGPL-3.0-or-later

// POST /v1/applications request body
pub struct CreateApplicationRequest {
    pub household_id: Uuid,
    pub programs_requested: Vec<canopy_reference::Program>,
    pub submission_channel: SubmissionChannel,
    pub authorized_representative_id: Option<Uuid>,
    pub received_at: Option<chrono::DateTime<chrono::Utc>>, // for mail/phone backdate
    pub submitted_by_role: SubmitterRole,
    /// Self-reported data for expedited screening (7 CFR 273.2(i)).
    /// Required when SNAP is in programs_requested; ignored for other programs.
    /// Uses applicant-attested values, NOT data from canopy-persons (ADR-001).
    pub expedited_screening_data: Option<ExpeditedScreeningData>,
    /// TANF-only: 'assistance' (cash) or 'non_assistance' (employment services, diversion).
    /// Required when TANF is in programs_requested; ignored for other programs.
    pub tanf_service_type: Option<TanfServiceType>,
}

pub enum SubmitterRole {
    Applicant,
    Worker,
    AuthorizedRep,
    AgencySystem,
}

pub enum TanfServiceType {
    Assistance,
    NonAssistance,
}

/// Self-reported data used ONLY for expedited screening (7 CFR 273.2(i)).
/// These are applicant attestations, not verified data.
pub struct ExpeditedScreeningData {
    pub gross_monthly_income: rust_decimal::Decimal,
    pub liquid_resources: rust_decimal::Decimal,
    pub monthly_rent_or_mortgage: rust_decimal::Decimal,
    pub monthly_utility_costs: rust_decimal::Decimal,
    pub is_migrant_farmworker: bool,
}

pub enum SubmissionChannel {
    Online,
    Phone,
    InPerson,
    Mail,
}

// SPDX-License-Identifier: AGPL-3.0-or-later

// GET /v1/applications/{id} response
pub struct ApplicationResponse {
    pub id: Uuid,
    pub household_id: Uuid,
    pub submitted_by: Uuid,
    pub programs: Vec<ApplicationProgramResponse>,
    pub submission_channel: SubmissionChannel,
    pub submitted_at: DateTime<Utc>,
    pub status: ApplicationStatus,
    pub interview_required: bool,
    pub interview_completed_at: Option<DateTime<Utc>>,
    pub interview_waived: bool,
    pub expedited_eligible: Option<bool>,
    pub processing_deadline: Option<NaiveDate>,
}

pub struct ApplicationProgramResponse {
    pub program: canopy_reference::Program,
    pub status: ProgramApplicationStatus,
    pub determination_id: Option<Uuid>,
}

Expedited service screening (7 CFR 273.2(i))

The expedited screening runs synchronously during POST /v1/applications before the 201 response.

IMPORTANT (ADR-001 compliance): The screening uses self-reported data from CreateApplicationRequest, NOT data fetched from canopy-persons. The request body must include expedited_screening_data with the fields below. This avoids a cross-service call during application submission and ensures the screening operates on the applicant’s own attestation (which is the federal intent — 7 CFR 273.2(i) specifies that screening uses information "furnished on the application").

The three tests (any one qualifies):

  1. Self-reported gross monthly income < $150 AND self-reported liquid resources (bank accounts, cash) < $100

  2. Self-reported combined gross monthly income + liquid resources < self-reported monthly rent/mortgage + monthly utility costs

  3. Household contains a migrant or seasonal farmworker with little or no income (self-attested)

If any test is met and SNAP is in programs_requested: - Set expedited_eligible = true - Set expedited_basis to the qualifying test - Set the SNAP entry in application_programs.processing_deadline = received_at + 7 days - Publish application.expedited_identified event

Per-program deadline calculation (runs for each program in programs_requested):

  • SNAP (expedited): received_at + 7 days

  • SNAP (non-expedited): received_at + 30 days

  • TANF: received_at + 30 days

  • Medicaid (standard): received_at + 45 days

  • Medicaid (disability): received_at + 90 days (set to 90 days if disability-related; can be downgraded to 45 if disability is not a factor after screening)

  • CHIP: received_at + 45 days

  • CAPS: received_at + 30 days

  • WIC: no federal processing deadline (set to NULL)

Events

All events published to canopy.events exchange. Per ADR coding conventions, no personal data, income amounts, or SSNs in event payloads — IDs, status codes, and timestamps only.

// application.submitted
{
    "application_id": "uuid",
    "household_id": "uuid",
    "programs_requested": ["snap", "medicaid"],
    "submission_channel": "online",
    "submitted_at": "2026-04-15T14:30:00Z"
}

// application.expedited_identified
{
    "application_id": "uuid",
    "household_id": "uuid",
    "expedited_basis": "low_income_assets",
    "processing_deadline": "2026-04-22"
}

// application.status_changed
{
    "application_id": "uuid",
    "old_status": "submitted",
    "new_status": "processing"
}

// application.withdrawn
{
    "application_id": "uuid",
    "household_id": "uuid",
    "withdrawn_at": "2026-04-16T10:00:00Z"
}

API endpoint contract

All endpoints require authenticated Bearer JWT (per canopy-api middleware). Roles: canopy-worker may submit and process applications; applicants submitting their own application require canopy-applicant role (portal only).

Method + Path Description Auth Notes

POST /v1/applications

Submit new application; runs expedited screening

canopy-worker, canopy-applicant

Returns 201 with ApplicationResponse

GET /v1/applications/{id}

Get application with program statuses

canopy-worker, canopy-applicant (own household only)

Returns 404 if not found or not authorized

PUT /v1/applications/{id}

Update application (programs, channel) while status is submitted/processing

canopy-worker

Returns 200; returns 409 if status is complete/withdrawn

DELETE /v1/applications/{id}

Withdraw application (soft delete)

canopy-worker, canopy-applicant (own household only)

Returns 204; publishes application.withdrawn

GET /v1/applications

List applications for household_id

canopy-worker

?household_id required; returns paginated list

POST /v1/applications/{id}/interview/waive

Record interview waiver

canopy-worker

Body: { reason: String }; returns 200

POST /v1/applications/{id}/interview/complete

Mark interview as completed

canopy-worker

Body: { completed_at: DateTime }; returns 200

POST /v1/applications/{id}/programs/{program}/determination

Record determination result from orchestrator

internal (canopy-eligibility only)

Body: { determination_id, status, denial_reason_codes }; returns 200

CLI Commands (ADR-007)

Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:

  • canopy application create — submit a new application with expedited screening

  • canopy application get <id> — get application with program statuses

  • canopy application update <id> — update application (while submitted/processing)

  • canopy application withdraw <id> — withdraw (soft-delete) an application

  • canopy application list --household-id <id> — list applications for a household

  • canopy application waive-interview <id> — record interview waiver

  • canopy application complete-interview <id> — mark interview as completed

Steps

Step 1: Database schema and migrations

Files: - services/canopy-applications/migrations/20260401000000_create_applications_table.sql - services/canopy-applications/migrations/20260401000001_create_application_programs_table.sql - services/canopy-applications/migrations/20260401000002_create_authorized_representatives_table.sql

Write migrations with the SQL above. Enable migrations in services/canopy-applications/src/main.rs:

boot.db.run_migrations().await.context("database migration failed")?;

Step 2: Domain types

Files: services/canopy-applications/src/domain.rs (new)

Define CreateApplicationRequest, ApplicationResponse, ApplicationProgramResponse, SubmissionChannel, ApplicationStatus, ProgramApplicationStatus as structs/enums. All derive Debug, Serialize, Deserialize, ToSchema. SubmissionChannel and ApplicationStatus derive Display, EnumString, EnumIter from strum.

Step 3: Store layer

Files: services/canopy-applications/src/store.rs (new)

pub struct ApplicationStore {
    pool: sqlx::PgPool,
}

impl ApplicationStore {
    pub async fn create(&self, req: &CreateApplicationRequest, expedited_result: ExpeditedResult) -> anyhow::Result<Application>;
    pub async fn get_by_id(&self, id: Uuid) -> anyhow::Result<Option<Application>>;
    pub async fn list_by_household(&self, household_id: Uuid, page: &Paginator) -> anyhow::Result<Vec<Application>>;
    pub async fn update_status(&self, id: Uuid, status: ApplicationStatus) -> anyhow::Result<()>;
    pub async fn withdraw(&self, id: Uuid) -> anyhow::Result<()>;
    pub async fn waive_interview(&self, id: Uuid, reason: &str) -> anyhow::Result<()>;
    pub async fn complete_interview(&self, id: Uuid, completed_at: DateTime<Utc>) -> anyhow::Result<()>;
    pub async fn record_determination(&self, id: Uuid, program: Program, det_id: Uuid, status: ProgramApplicationStatus, denial_codes: Vec<String>) -> anyhow::Result<()>;
}

Use sqlx compile-time verified queries where schema is stable.

Step 4: Expedited screening logic

Files: services/canopy-applications/src/expedited.rs (new)

pub struct ExpeditedScreener {
    persons_client: PersonsClient,  // HTTP client for canopy-persons
}

impl ExpeditedScreener {
    pub async fn screen(&self, household_id: Uuid, channel: &SubmissionChannel) -> ExpeditedResult;
}

pub struct ExpeditedResult {
    pub eligible: Option<bool>,  // None if screening could not complete
    pub basis: Option<ExpeditedBasis>,
}

pub enum ExpeditedBasis {
    LowIncomeAndAssets,   // income < $150 AND assets < $100
    IncomeVsExpenses,     // income + assets < rent + utilities
    MigrantFarmworker,
}

If persons_client returns an error: log warning, return ExpeditedResult { eligible: None, basis: None }. Never fail the application because expedited screening failed.

Step 5: API routes

Files: services/canopy-applications/src/api/mod.rs

Implement all endpoints listed in the contract. All handlers take State<AppState> and extract Extension<AuthUser> from the auth middleware. Return Result<impl IntoResponse, ApiError> where ApiError maps to RFC 9457 Problem Details.

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/v1/applications", post(create_application).get(list_applications))
        .route("/v1/applications/:id", get(get_application).put(update_application).delete(withdraw_application))
        .route("/v1/applications/:id/interview/waive", post(waive_interview))
        .route("/v1/applications/:id/interview/complete", post(complete_interview))
        .route("/v1/applications/:id/programs/:program/determination", post(record_determination))
}

Step 6: Event publishing

Files: services/canopy-applications/src/main.rs

In bootstrap(), wire the publisher from canopy-mq. Pass publisher to handlers via AppState. Publish events at appropriate points in each handler (after database writes succeed).

Step 7: Integration tests

Files: services/canopy-applications/tests/application_test.rs (new)

Using testcontainers-rs: - Start PostgreSQL container, run migrations - POST application → 201 → verify record in DB - GET application → 200 → verify response shape - POST with SNAP programs, low income/assets → verify expedited_eligible = true (mock canopy-persons) - DELETE application → 204 → verify active = false in DB - POST determination result → verify program status updated

Files Touched

File Change

services/canopy-applications/migrations/20260401000000_create_applications_table.sql

New: applications table migration

services/canopy-applications/migrations/20260401000001_create_application_programs_table.sql

New: application_programs table migration

services/canopy-applications/migrations/20260401000002_create_authorized_representatives_table.sql

New: authorized_representatives table migration

services/canopy-applications/src/domain.rs

New: domain types

services/canopy-applications/src/store.rs

New: database queries

services/canopy-applications/src/expedited.rs

New: expedited service screening logic

services/canopy-applications/src/api/mod.rs

Replace empty Router::new() with full route set

services/canopy-applications/src/main.rs

Enable migrations; wire publisher; add expedited screener to AppState

services/canopy-applications/tests/application_test.rs

New: integration tests

Verification

  1. cargo nextest run --workspace --lib — unit tests pass

  2. cargo xtask dev reload (or cargo xtask dev restart for schema changes)

  3. cargo nextest run -p canopy-applications — integration tests pass

  4. Manual: POST /v1/applications with programs_requested: ["snap"] and household with income < $150 → verify expedited_eligible: true in response

  5. Manual: GET /v1/applications/{id} → verify ApplicationResponse shape matches spec

  6. cargo clippy --all-targets — -D warnings — zero warnings

Documentation Updates

  • .claude/docs/services.md — add canopy-applications endpoints, events, tables

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/CLAUDE.md — update Feature Status table: canopy-applications status → in-progress

Errata

Single migration file (deviation)

Plan specified 3 separate migration files. Implementation uses a single migration file containing all 3 tables. Reason: simpler to manage during scaffold phase; can split later if needed for incremental rollout.

No transaction on create (known gap)

create_application writes the application row then loops over programs to create application_programs entries. These are not wrapped in a database transaction. If a program entry fails, the application exists without all program entries. Should be wrapped in sqlx::Pool::begin() / tx.commit() in a follow-up.

Potential Improvements

  • Wrap create_application + program entry creation in a transaction

  • Validate programs_requested values against canopy_reference::Program enum

  • Add PUT /v1/applications/{id} support for updating programs_requested (add/remove programs)

  • Add authorized representative management endpoints (POST/GET/DELETE /v1/authorized-representatives)

  • Add application search by submitter, date range, and status


Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):

  • #314 — Wrap create_application + program loop in a transaction (from Known Gaps)

Tracked follow-ups (filed 2026-05-04 during PI sweep):

  • #399 — Validate programs_requested against canopy_reference::Program enum

  • #400 — PUT /v1/applications/{id} for programs_requested updates

  • #401 — Authorized representative endpoints

  • #402 — Application search by submitter / date / status

Edit this page · default