Plan: Enrollment Partial-Month Retention (Issue #407)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Forward-only migration |
Done (2026-05-11) |
2 |
Add |
Done (2026-05-11) |
3 |
Replace the untyped |
Done (2026-05-11) |
4 |
Update |
Done (2026-05-11) |
5 |
Unit + integration tests. Three unit tests in a new |
Done (2026-05-11) |
6 |
Docs. CHANGELOG |
Done (2026-05-11) |
Issue: #407
Branch: feat/enrollment-partial-month-retention
Labels: type::feature, priority::medium, service::enrollment, program::snap, workflow::ready
Context
PAMMS 2415 says: when a SNAP case closes mid-month and the household has already received the full month’s allotment, Georgia allows the household to retain that month’s benefits. Today canopy-enrollment’s terminate_enrollment handler records status = 'terminated' and a terminated_date but stores nothing about whether the in-month issuance is retained or owed back. The downstream consumers of that fact are:
-
Workers reading the household issuance ledger in canopy-web. They need to advise the household whether EBT funds remain spendable.
-
canopy-appeals' continued-benefits overpayment calculation in
services/canopy-appeals/src/continued_benefits.rs:41-58. The current implementation counts whole-month issuances toward overpayment unconditionally; the Potential Improvements note in the archivedcanopy-enrollment-household-issuancesplan (lines 278-284) explicitly defers this rule pending a jurisdiction parameter.
This plan adds the two columns to snap_enrollments so the rule has a place to live, wires the closure handler to populate them, and externalises the cutoff day to jurisdiction.toml per ADR-011. Consumption by canopy-appeals' overpayment math is out of scope for this plan — it will follow once the data is reliably written.
ADR-001 (per-service isolation) keeps the columns inside canopy-enrollment’s own database. ADR-016 (forward-only migrations) governs the schema change.
Why Design A (extend snap_enrollments) and not Design B (new enrollment_certifications table)
The original plan invented an enrollment_certifications table that does not exist anywhere in the codebase. snap_enrollments already carries the certification window (certification_start_date, certification_end_date) and the termination columns (terminated_reason, terminated_date). Two boolean/date columns describing the same termination event belong on the same row — a separate 1:1 table would force a join on every read of the enrollment for no normalisation gain (these columns are not part of a multi-valued relationship). Picking A.
Scope
In scope:
-
Two-column forward-only migration on
snap_enrollments. -
Typed termination-request body replacing
Json<serde_json::Value>. -
jurisdiction.tomlcutoff parameter + matchingcitations.tomlentry. -
Closure-time computation of
(partial_retention, retained_through). -
Unit + integration tests.
-
OpenAPI snapshot regeneration.
Out of scope:
-
canopy-appeals overpayment-math change. Once this plan lands, a follow-up issue should make
continued_benefits::compute_overpaymentconsult the retention fields. Not this plan. -
Non-SNAP retention rules (TANF, Medicaid). PAMMS 2415 is SNAP-specific.
-
Notice template wording. Existing NOA text already covers the household-side message; only the worker-portal view needs the structured field, and #392 picks that up.
-
Recoupment of partial-month benefits when retention does not apply (e.g., fraud). That belongs to the overpayment pipeline.
-
EBT-system-side reflection. EBT account already independently knows funds are loaded; this plan only adds canopy’s record of the rule.
Dependencies
-
Archived canopy-enrollment-household-issuances.adoc — origin of the deferred rule (lines 278-284).
-
worker-portal-program-action-handlers.adoc(#392) is a downstream consumer: oncepartial_retentionlands, #392 can surface "Retained until {date}" on the SNAP case tab. Landing order: this plan first. -
ADR-011 (
cargo xtask policy auditwill fail if the newpartial_retention_cutoff_dayparameter is added tojurisdiction.tomlwithout a matchingcitations.tomlentry).
Design
Schema (Step 1)
-- services/canopy-enrollment/migrations/20260511000000_add_partial_retention_to_snap_enrollments.sql
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Per ADR-001: canopy-enrollment owns this schema.
-- Per ADR-016: forward-only; correcting changes ship as new migrations.
-- Per PAMMS 2415: surface mid-month closure retention so workers and
-- downstream overpayment math can distinguish retained vs recoverable months.
ALTER TABLE snap_enrollments
ADD COLUMN partial_retention BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN retained_through DATE;
Default false / NULL keeps existing rows correct (no retention asserted for terminations recorded before this column existed).
Citation + jurisdiction parameter (Step 2)
rulesets/georgia/jurisdiction.toml — add under the existing [snap] or [snap.closure] section:
[snap.closure]
partial_retention_cutoff_day = 16 # PAMMS 2415: closures on or after this day of month retain the issued benefit
rulesets/georgia/citations.toml — add:
[citations."snap.closure.partial_retention_cutoff_day"]
value = 16
authority = "pamms"
source_ref = "dfcs-snap/modules/snap/pages/2415.adoc"
effective_date = "2026-03-01"
verified_date = "2026-05-11"
notes = "Closures on or after this calendar day of the month allow the household to retain the already-issued allotment for that month (PAMMS 2415)."
cargo xtask policy audit verifies the pair.
Typed request body + closure logic (Steps 3-4)
Replace the existing untyped body in services/canopy-enrollment/src/api/mod.rs:348-363:
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct TerminateEnrollmentRequest {
pub reason: String,
/// Defaults to today (server clock) if omitted. Workers may backdate for
/// closures that took effect before the worker logged the action.
pub closure_date: Option<NaiveDate>,
}
async fn terminate_enrollment(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Extension(closure_params): Extension<std::sync::Arc<closure::ClosureParams>>,
Path(id): Path<EnrollmentId>,
Json(req): Json<TerminateEnrollmentRequest>,
) -> Result<Json<SnapEnrollment>, ApiError> {
claims.require_service_caller()?;
let close_date = req.closure_date.unwrap_or_else(|| Utc::now().date_naive());
let (partial_retention, retained_through) =
closure::partial_retention(close_date, closure_params.cutoff_day);
store::terminate_enrollment(
state.db.inner(), id, &req.reason, close_date,
partial_retention, retained_through,
)
.await
.map_err(ApiError::from)?
.map(Json)
.ok_or_else(|| ApiError::NotFound("enrollment not found".into()))
}
New pure-function module services/canopy-enrollment/src/closure.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Partial-month retention rule per PAMMS 2415.
use chrono::{Datelike, Months, NaiveDate};
pub struct ClosureParams {
/// Calendar day of month on/after which the household retains the
/// already-issued allotment. Loaded from
/// `jurisdiction.toml :: snap.closure.partial_retention_cutoff_day`.
pub cutoff_day: u32,
}
/// Returns `(partial_retention, retained_through)` for a closure on
/// `close_date`. Retention is asserted only when the closure happens on
/// the cutoff day or later -- earlier closures fall under whole-month
/// recoupment per the existing overpayment pipeline.
pub fn partial_retention(close_date: NaiveDate, cutoff_day: u32) -> (bool, Option<NaiveDate>) {
if close_date.day() < cutoff_day {
return (false, None);
}
let end_of_month = close_date
.with_day(1)
.and_then(|d| d.checked_add_months(Months::new(1)))
.and_then(|d| d.pred_opt())
.unwrap_or(close_date);
(true, Some(end_of_month))
}
Store update at services/canopy-enrollment/src/store.rs:128-146:
pub async fn terminate_enrollment(
pool: &PgPool,
id: EnrollmentId,
reason: &str,
terminated_date: NaiveDate,
partial_retention: bool,
retained_through: Option<NaiveDate>,
) -> Result<Option<SnapEnrollment>, sqlx::Error> {
sqlx::query_as::<_, SnapEnrollment>(
r#"UPDATE snap_enrollments
SET status = 'terminated', terminated_reason = $2,
terminated_date = $3,
partial_retention = $4,
retained_through = $5,
updated_at = now()
WHERE id = $1 AND active = true
RETURNING *"#,
)
.bind(id).bind(reason).bind(terminated_date)
.bind(partial_retention).bind(retained_through)
.fetch_optional(pool).await
}
SnapEnrollment (services/canopy-enrollment/src/domain.rs) gains two new fields:
pub partial_retention: bool,
pub retained_through: Option<NaiveDate>,
ClosureParams is constructed in services/canopy-enrollment/src/main.rs from the loaded jurisdiction.toml (mirroring how issuance::IssuanceParams is built and Extension-injected today) and registered as an Extension<Arc<ClosureParams>> on the router.
Steps
Step 1: Migration
Files: services/canopy-enrollment/migrations/20260511000000_add_partial_retention_to_snap_enrollments.sql
Forward-only ALTER TABLE adding two columns. SPDX header. Comment cites ADR-001 + ADR-016 + PAMMS 2415.
Step 2: Jurisdiction parameter + citation
Files: rulesets/georgia/jurisdiction.toml, rulesets/georgia/citations.toml
Add [snap.closure] table with partial_retention_cutoff_day = 16. Add matching [citations."snap.closure.partial_retention_cutoff_day"] entry. Run cargo xtask policy audit to confirm clean.
Step 3: Typed request + closure module
Files: services/canopy-enrollment/src/closure.rs (new), services/canopy-enrollment/src/lib.rs, services/canopy-enrollment/src/api/mod.rs:29-50 (add TerminateEnrollmentRequest), services/canopy-enrollment/src/api/mod.rs:348-363 (rewrite handler), services/canopy-enrollment/src/main.rs (load + extension-inject ClosureParams).
Replace Json<serde_json::Value> with the typed struct. Default closure_date to today when absent. Call closure::partial_retention and pass results into store::terminate_enrollment.
Step 4: Store + domain + OpenAPI
Files: services/canopy-enrollment/src/store.rs:128-146 (extra parameters), services/canopy-enrollment/src/domain.rs (two new struct fields, FromRow derivation already handles the columns), services/canopy-enrollment/src/api/mod.rs:52-71 (ApiDoc schemas list — add TerminateEnrollmentRequest).
Run cargo xtask api-docs to regenerate docs/modules/ROOT/openapi/canopy-enrollment.json.
Step 5: Tests
Files: services/canopy-enrollment/src/closure.rs (unit tests inline), services/canopy-enrollment/tests/partial_retention_test.rs (new).
Unit tests:
-
closure::partial_retentionon day 15 with cutoff 16 →(false, None)(boundary: strictly before cutoff). -
closure::partial_retentionon day 16 with cutoff 16 →(true, Some(end_of_month)). -
closure::partial_retentionon day 1 with cutoff 16 →(false, None). -
closure::partial_retentionon the last calendar day of February (28/29) with cutoff 16 →(true, Some(last_day_of_feb)).
Integration test: create an enrollment with the existing test helper, POST /v1/enrollments/{id}/terminate with closure_date set to the 20th of some month, GET the enrollment, assert partial_retention == true and retained_through == end-of-month. Second case: same flow with closure_date on the 5th, assert partial_retention == false.
Step 6: Docs
Files: CHANGELOG.adoc (=== Added under == Unreleased), docs/modules/ROOT/pages/services/canopy-enrollment.adoc (termination-flow section), .claude/docs/services.md (if terminate endpoint description mentions the body shape).
Run cargo xtask policy audit + cargo xtask docs plan-lint — both clean.
Files Touched
| File | Change |
|---|---|
|
New forward-only migration adding |
|
New |
|
Matching citation entry for |
|
New module: |
|
|
|
Typed |
|
|
|
|
|
Load |
|
New integration test |
|
Regenerated snapshot |
|
Termination-flow section adds retention semantics |
|
|
Verification
-
cargo nextest run -p canopy-enrollment— unit + integration tests pass. -
cargo xtask policy audit— citations.toml validates against the new jurisdiction parameter. -
cargo xtask api-docs— OpenAPI snapshot regenerates clean (verify viagit diff). -
cargo xtask dev refresh && cargo nextest run -p canopy-enrollment --test partial_retention_test— integration test against live DB passes. -
Manual smoke: POST
/v1/enrollments/{id}/terminatewith{"reason":"voluntary","closure_date":"2026-06-20"}; GET the enrollment; assertpartial_retention: true, retained_through: "2026-06-30". -
cargo xtask docs plan-lint— 0 violations. -
cargo xtask validate— full battery green.
Documentation Updates
-
CHANGELOG.adoc—=== Addedentry under== Unreleasedciting PAMMS 2415 + ADR-016 + ADR-001 -
docs/modules/ROOT/pages/services/canopy-enrollment.adoc— termination-flow section adds the retention semantics + cutoff parameter -
docs/modules/ROOT/openapi/canopy-enrollment.json— regenerated viacargo xtask api-docs -
Plan archive: move this file to
plans/archive/post-merge