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 issubmittedorprocessing -
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):
-
Self-reported gross monthly income < $150 AND self-reported liquid resources (bank accounts, cash) < $100
-
Self-reported combined gross monthly income + liquid resources < self-reported monthly rent/mortgage + monthly utility costs
-
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 |
|---|---|---|---|
|
Submit new application; runs expedited screening |
canopy-worker, canopy-applicant |
Returns 201 with ApplicationResponse |
|
Get application with program statuses |
canopy-worker, canopy-applicant (own household only) |
Returns 404 if not found or not authorized |
|
Update application (programs, channel) while status is submitted/processing |
canopy-worker |
Returns 200; returns 409 if status is complete/withdrawn |
|
Withdraw application (soft delete) |
canopy-worker, canopy-applicant (own household only) |
Returns 204; publishes application.withdrawn |
|
List applications for household_id |
canopy-worker |
?household_id required; returns paginated list |
|
Record interview waiver |
canopy-worker |
Body: { reason: String }; returns 200 |
|
Mark interview as completed |
canopy-worker |
Body: { completed_at: DateTime }; returns 200 |
|
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 |
|---|---|
|
New: applications table migration |
|
New: application_programs table migration |
|
New: authorized_representatives table migration |
|
New: domain types |
|
New: database queries |
|
New: expedited service screening logic |
|
Replace empty Router::new() with full route set |
|
Enable migrations; wire publisher; add expedited screener to AppState |
|
New: integration tests |
Verification
-
cargo nextest run --workspace --lib— unit tests pass -
cargo xtask dev reload(orcargo xtask dev restartfor schema changes) -
cargo nextest run -p canopy-applications— integration tests pass -
Manual:
POST /v1/applicationswithprograms_requested: ["snap"]and household with income < $150 → verifyexpedited_eligible: truein response -
Manual:
GET /v1/applications/{id}→ verify ApplicationResponse shape matches spec -
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_requestedvalues againstcanopy_reference::Programenum -
Add
PUT /v1/applications/{id}support for updatingprograms_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):