Plan: Enrollment Partial-Month Retention (Issue #407)

On this page

Status

Step Description Status

1

Forward-only migration services/canopy-enrollment/migrations/20260511000000_add_partial_retention_to_snap_enrollments.sql adding partial_retention BOOLEAN NOT NULL DEFAULT false and retained_through DATE to snap_enrollments (ADR-016; ADR-001 — columns live in canopy-enrollment’s own DB).

Done (2026-05-11)

2

Add jurisdiction.toml parameter snap.closure.partial_retention_cutoff_day = 16 (PAMMS 2415 threshold) and a matching [citations."snap.closure.partial_retention_cutoff_day"] entry in rulesets/georgia/citations.toml so the cutoff is externalised per ADR-011.

Done (2026-05-11)

3

Replace the untyped Json<serde_json::Value> body on terminate_enrollment (services/canopy-enrollment/src/api/mod.rs:348-363) with a typed TerminateEnrollmentRequest { reason: String, closure_date: Option<NaiveDate> }. Default closure_date to Utc::now().date_naive() when absent. Compute (partial_retention, retained_through) and pass through to the store.

Done (2026-05-11)

4

Update store::terminate_enrollment (services/canopy-enrollment/src/store.rs:128-146) to write the two new columns. Update the SnapEnrollment struct (services/canopy-enrollment/src/domain.rs) and its FromRow to expose them. Surface them on the SnapEnrollment OpenAPI schema.

Done (2026-05-11)

5

Unit + integration tests. Three unit tests in a new crate::closure module (or inline in api/mod.rs): closure on the 15th → retained_through = end-of-month; closure on the 1st → partial_retention = false; closure on the last day → retained_through = that day. One integration test in services/canopy-enrollment/tests/partial_retention_test.rs that creates an enrollment, posts a mid-month termination, GETs the enrollment, asserts the two fields.

Done (2026-05-11)

6

Docs. CHANGELOG === Added (PAMMS 2415 retention surfacing). Update docs/modules/ROOT/pages/services/canopy-enrollment.adoc termination-flow doc. Regenerate docs/modules/ROOT/openapi/canopy-enrollment.json. Run cargo xtask policy audit to confirm the new citation entry validates.

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:

  1. Workers reading the household issuance ledger in canopy-web. They need to advise the household whether EBT funds remain spendable.

  2. 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 archived canopy-enrollment-household-issuances plan (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.toml cutoff parameter + matching citations.toml entry.

  • 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_overpayment consult 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: once partial_retention lands, #392 can surface "Retained until {date}" on the SNAP case tab. Landing order: this plan first.

  • ADR-011 (cargo xtask policy audit will fail if the new partial_retention_cutoff_day parameter is added to jurisdiction.toml without a matching citations.toml entry).

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_retention on day 15 with cutoff 16 → (false, None) (boundary: strictly before cutoff).

  • closure::partial_retention on day 16 with cutoff 16 → (true, Some(end_of_month)).

  • closure::partial_retention on day 1 with cutoff 16 → (false, None).

  • closure::partial_retention on 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

services/canopy-enrollment/migrations/20260511000000_add_partial_retention_to_snap_enrollments.sql

New forward-only migration adding partial_retention + retained_through

rulesets/georgia/jurisdiction.toml

New [snap.closure] partial_retention_cutoff_day = 16 entry

rulesets/georgia/citations.toml

Matching citation entry for snap.closure.partial_retention_cutoff_day

services/canopy-enrollment/src/closure.rs

New module: ClosureParams + pure partial_retention function + unit tests

services/canopy-enrollment/src/lib.rs

pub mod closure;

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

Typed TerminateEnrollmentRequest; rewrite terminate_enrollment handler; register schema in ApiDoc

services/canopy-enrollment/src/store.rs

terminate_enrollment gains partial_retention + retained_through parameters; UPDATE writes both columns

services/canopy-enrollment/src/domain.rs

SnapEnrollment gains two fields (FromRow + ToSchema)

services/canopy-enrollment/src/main.rs

Load ClosureParams from jurisdiction config; register as Extension<Arc<ClosureParams>>

services/canopy-enrollment/tests/partial_retention_test.rs

New integration test

docs/modules/ROOT/openapi/canopy-enrollment.json

Regenerated snapshot

docs/modules/ROOT/pages/services/canopy-enrollment.adoc

Termination-flow section adds retention semantics

CHANGELOG.adoc

=== Added entry citing PAMMS 2415

Verification

  1. cargo nextest run -p canopy-enrollment — unit + integration tests pass.

  2. cargo xtask policy audit — citations.toml validates against the new jurisdiction parameter.

  3. cargo xtask api-docs — OpenAPI snapshot regenerates clean (verify via git diff).

  4. cargo xtask dev refresh && cargo nextest run -p canopy-enrollment --test partial_retention_test — integration test against live DB passes.

  5. Manual smoke: POST /v1/enrollments/{id}/terminate with {"reason":"voluntary","closure_date":"2026-06-20"}; GET the enrollment; assert partial_retention: true, retained_through: "2026-06-30".

  6. cargo xtask docs plan-lint — 0 violations.

  7. cargo xtask validate — full battery green.

Documentation Updates

  • CHANGELOG.adoc — === Added entry under == Unreleased citing 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 via cargo xtask api-docs

  • Plan archive: move this file to plans/archive/ post-merge

Edit this page · default