Plan: CAPS Eligibility (canopy-caps)
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Steps
- Step 1: Database Migration
- Step 2: Cargo.toml + Parameter Loader + Rules Client + Service Bootstrap
- Step 3: Store Layer (Models + CRUD)
- Step 4: Income Eligibility via canopy-rules (50% SMI / 85% SMI)
- Step 5: Activity Requirements Verification
- Step 6: Copayment Determination
- Step 7: Provider Authorization + Rate Lookup
- Step 8: Determination Orchestrator + JWS Signing per ADR-002
- Step 9: API Handlers
- Step 10: Event Publishing
- Step 11: JDM Rulesets
- Step 12: Integration Tests
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
DB migration: |
Done (2026-04-13) |
2 |
Cargo.toml dependencies + |
Done (2026-04-13) |
3 |
Store layer: domain models + CRUD queries for determinations and authorizations |
Done (2026-04-13) |
4 |
Income eligibility evaluation via canopy-rules (50% SMI initial / 85% SMI continued) |
Done (2026-04-13) |
5 |
Activity requirements verification (employment/education/training, 24 hrs/week minimum) |
Done (2026-04-13) |
6 |
Copayment determination (sliding scale by income tier from jurisdiction.toml) |
Done (2026-04-13) |
7 |
Provider authorization + rate lookup via canopy-rules |
Done (2026-04-13) |
8 |
Determination orchestrator + JWS signing per ADR-002 |
Done (2026-04-13) |
9 |
API handlers: |
Done (2026-04-13) |
10 |
Event publishing: |
Done (2026-04-13) |
11 |
JDM rulesets: |
Done (2026-04-18) |
12 |
Integration tests (10 cases: approval, denial, copayment, authorization, signing, events) |
Done (2026-04-13) — |
Epic: &31
Branch: feature/caps-eligibility
Labels: type::feature, priority::medium, program::caps, service::caps, workflow::ready, federal-partner::acf
Context
The Child Care and Development Fund (CCDF, 45 CFR Parts 98-99) is the primary federal funding source for child care subsidies. In Georgia, the program is administered as CAPS (Childcare and Parent Services) by the Department of Early Care and Learning (DECAL) with eligibility determined by DFCS.
CAPS eligibility requires:
-
Income test — family income at or below a state-defined percentage of the State Median Income (SMI). Georgia uses 50% SMI for initial eligibility and 85% SMI for continued eligibility (per jurisdiction.toml
income_limit_initial_pct_smiandincome_limit_continued_pct_smi). -
Activity requirement — parent/caretaker must be engaged in an approved activity (employment, education, job training) totaling at least 24 hours/week (per jurisdiction.toml
min_work_hours_per_week). -
Age requirement — child must be under age 13 (or under 19 for children with special needs per 45 CFR 98.20(a)(1)(ii)).
-
Citizenship/immigration status — child must be a US citizen or qualified non-citizen.
CAPS is the simplest compliance posture in Canopy: no FTI, no IEVS, no SSA data. Income is applicant-attested or verified through non-restricted sources (employer verification, pay stubs).
Per ADR-001, canopy-caps is an independent service with its own PostgreSQL database. Per ADR-002, CAPS determinations are returned as signed JWS payloads via canopy-eligibility. Per ADR-003, all eligibility logic is in versioned JDM rulesets evaluated by canopy-rules.
Regulatory basis
-
45 CFR Part 98 — CCDF eligibility and program requirements
-
45 CFR 98.20 — Eligibility criteria (income, activity, age, citizenship)
-
45 CFR 98.21 — Eligibility determination process
-
45 CFR 98.44 — Child care services payment rates
-
45 CFR 98.45 — Equal access provisions (payment rates, copayments)
-
Georgia CAPS Policy Manual — State-specific income limits, copayment schedule, provider rate structure
Key parameters (Georgia)
| Parameter | Value | Source |
|---|---|---|
SMI HH=3 |
$60,218/year ($5,018.17/month) |
|
Initial income limit (50% SMI, HH=3) |
$2,509.08/month |
|
Continued income limit (85% SMI, HH=3) |
$4,265.44/month |
|
Min activity hours |
24 hrs/week |
|
Authorization period |
12 months |
|
Age limit (standard) |
< 13 years |
45 CFR 98.20(a)(1)(i) |
Age limit (special needs) |
< 19 years |
45 CFR 98.20(a)(1)(ii) |
Scope
In scope:
-
CAPS income eligibility evaluation (initial 50% SMI and continued 85% SMI thresholds)
-
Activity requirement verification (employment hours, education enrollment, training participation)
-
Age eligibility (under 13, or under 19 with documented special needs)
-
Copayment calculation based on family size and income tier (flat dollar from jurisdiction.toml tier table)
-
Provider authorization creation with market-rate lookup via canopy-rules
-
Determination signing via canopy-eligibility (ADR-002)
-
Event publishing:
caps.determined,caps.authorization_created(IDs only — no PHI per ADR-004)
Out of scope:
-
Provider management and licensing (external DECAL system)
-
Parent fee collection and payment processing
-
Quality Rated provider bonus calculations
-
CCDF reporting (ACF-801, ACF-800) — separate plan when Phase 5 begins
-
Waitlist management — deferred to post-Phase 5
Dependencies
This plan depends on:
-
persons-household-model (must be complete): household composition, child age, citizenship/immigration status
-
rules-engine (must be complete): canopy-rules must evaluate CAPS rulesets
-
determination-signing (must be complete): JWS signing infrastructure per ADR-002
-
reference-extensions (must be complete):
DeterminationStatusenum variants -
application-intake (must be complete): application creation and lifecycle management
-
eligibility-orchestrator (must be complete): CAPS determination triggered via canopy-eligibility
Design
Eligibility evaluation flow
-
canopy-eligibility receives determination request for CAPS program
-
canopy-eligibility calls canopy-caps
POST /v1/determine -
canopy-caps loads
CapsParameterTable(SMI fromsmi-2026.json, config fromjurisdiction.toml[caps]) -
canopy-caps calls canopy-rules to evaluate
{jurisdiction}-caps-eligibilityruleset -
canopy-caps evaluates activity requirement locally (boolean gate, caseworker-verified)
-
canopy-caps evaluates age gate:
child_age < 13(or< 19for special needs) -
If all three gates pass: canopy-caps calls canopy-rules for copayment determination
-
canopy-caps builds and signs determination per ADR-002
-
If eligible: canopy-caps creates provider authorization with rate lookup
-
canopy-caps persists determination + authorization
-
canopy-caps publishes events and returns signed determination
Income threshold computation
threshold = smi_for_family_size * pct / 100
For Georgia HH=3, initial:
threshold = $60,218 / 12 * 50 / 100 = $5,018.17 * 0.50 = $2,509.08/month
For Georgia HH=3, continued:
threshold = $60,218 / 12 * 85 / 100 = $5,018.17 * 0.85 = $4,265.44/month
Database schema (canopy-caps database)
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Per ADR-001: canopy-caps owns this schema; no other service queries it directly
CREATE TABLE caps_applications (
id UUID PRIMARY KEY,
application_id UUID NOT NULL, -- FK to canopy-applications (logical, not enforced)
household_id UUID NOT NULL,
child_person_id UUID NOT NULL,
child_age_years INTEGER NOT NULL,
child_has_special_needs BOOLEAN NOT NULL DEFAULT FALSE,
household_size INTEGER NOT NULL,
provider_id TEXT,
eligibility_type TEXT NOT NULL DEFAULT 'initial' CHECK (eligibility_type IN ('initial', 'continued')),
jurisdiction TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE caps_determinations (
id UUID PRIMARY KEY,
application_id UUID NOT NULL,
household_id UUID NOT NULL,
child_person_id UUID NOT NULL,
determination_status TEXT NOT NULL,
income_eligible BOOLEAN NOT NULL,
activity_eligible BOOLEAN NOT NULL,
age_eligible BOOLEAN NOT NULL,
copayment_weekly_cents INTEGER,
authorized_weekly_hours INTEGER,
effective_date DATE NOT NULL,
end_date DATE,
denial_reasons TEXT[],
ruleset_version TEXT NOT NULL,
jws_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE caps_authorizations (
id UUID PRIMARY KEY,
determination_id UUID NOT NULL REFERENCES caps_determinations(id),
child_person_id UUID NOT NULL,
provider_id TEXT NOT NULL,
authorization_status TEXT NOT NULL CHECK (authorization_status IN (
'active', 'suspended', 'terminated', 'expired'
)),
weekly_hours INTEGER NOT NULL,
rate_cents_per_hour INTEGER NOT NULL,
copayment_weekly_cents INTEGER NOT NULL,
effective_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Copayment tier table
Copayments are flat dollar amounts from a jurisdiction-specific tier table stored in jurisdiction.toml as an array of [min_pct_smi, max_pct_smi, weekly_copay_cents]:
# In jurisdiction.toml under [caps]
copayment_tiers = [
[0, 25, 0], # 0-25% SMI: $0/week
[26, 50, 2700], # 26-50% SMI: $27/week
[51, 65, 4500], # 51-65% SMI: $45/week
[66, 75, 6500], # 66-75% SMI: $65/week
[76, 85, 8500], # 76-85% SMI: $85/week
]
The copayment lookup computes the family’s income as a percentage of SMI, then finds the matching tier row and returns weekly_copay_cents.
Steps
Step 1: Database Migration
Files: services/canopy-caps/migrations/20260401000000_create_caps_tables.sql
Create caps_applications, caps_determinations, and caps_authorizations tables using the SQL from the Design section above, plus performance indexes:
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Per ADR-001: canopy-caps owns this schema; no other service queries it directly
-- Tables (see Design > Database Schema for full CREATE TABLE statements)
CREATE INDEX idx_caps_applications_app ON caps_applications(application_id);
CREATE INDEX idx_caps_applications_household ON caps_applications(household_id);
CREATE INDEX idx_caps_applications_child ON caps_applications(child_person_id);
CREATE INDEX idx_caps_determinations_application ON caps_determinations(application_id);
CREATE INDEX idx_caps_determinations_household ON caps_determinations(household_id);
CREATE INDEX idx_caps_determinations_child ON caps_determinations(child_person_id);
CREATE INDEX idx_caps_determinations_status ON caps_determinations(determination_status);
CREATE INDEX idx_caps_determinations_effective ON caps_determinations(effective_date);
CREATE INDEX idx_caps_authorizations_determination ON caps_authorizations(determination_id);
CREATE INDEX idx_caps_authorizations_child ON caps_authorizations(child_person_id);
CREATE INDEX idx_caps_authorizations_provider ON caps_authorizations(provider_id);
CREATE INDEX idx_caps_authorizations_status ON caps_authorizations(authorization_status);
Run with sqlx migrate run on the postgres-caps instance (port 5438).
Uncomment the migration runner in services/canopy-caps/src/main.rs.
Error handling: if the migration fails (e.g., table already exists), sqlx::migrate!() returns sqlx::migrate::MigrateError. The service should fail to start with a clear log message rather than silently proceeding with a stale schema.
Step 2: Cargo.toml + Parameter Loader + Rules Client + Service Bootstrap
Files: services/canopy-caps/Cargo.toml, services/canopy-caps/src/params.rs, services/canopy-caps/src/rules_client.rs, services/canopy-caps/src/main.rs, services/canopy-caps/src/state.rs, services/canopy-caps/src/errors.rs, services/canopy-caps/src/lib.rs
Cargo.toml dependencies
Add runtime dependencies: axum, tokio, sqlx (with postgres, runtime-tokio, tls-rustls, uuid, chrono, migrate), serde, serde_json, chrono, uuid, reqwest, lapin, rust_decimal, tracing, anyhow, toml, utoipa, canopy-signing (workspace). Dev dependencies: wiremock, tokio (with macros, rt-multi-thread).
CapsParameterTable
// services/canopy-caps/src/params.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use rust_decimal::Decimal;
/// All CAPS parameters loaded at startup, indexed by household size.
/// SMI loaded from rulesets/federal/smi-2026.json.
/// CAPS config from rulesets/{jurisdiction}/jurisdiction.toml [caps] section.
#[derive(Debug, Clone)]
pub struct CapsParameterTable {
/// State Median Income by household size (annual dollars)
pub smi_by_hh_size: HashMap<u32, Decimal>,
/// Additional person increment for SMI (HH size > 6)
pub smi_additional_person: Decimal,
/// Initial eligibility threshold as percentage of SMI (e.g., 50)
pub initial_pct_smi: u32,
/// Continued eligibility threshold as percentage of SMI (e.g., 85)
pub continued_pct_smi: u32,
/// Minimum activity hours per week (e.g., 24)
pub min_work_hours_per_week: u32,
/// Authorization period in months (e.g., 12)
pub authorization_period_months: u32,
/// Copayment tiers: [(min_pct_smi, max_pct_smi, weekly_copay_cents)]
pub copayment_tiers: Vec<(u32, u32, i32)>,
}
impl CapsParameterTable {
/// Load CAPS parameters from federal SMI file + jurisdiction.toml.
pub fn load(rulesets_dir: &Path, jurisdiction: &str) -> Result<Self> {
// 1. Load SMI from rulesets/federal/smi-2026.json
let smi_path = rulesets_dir.join("federal/smi-2026.json");
let smi_raw = std::fs::read_to_string(&smi_path)
.with_context(|| format!("reading {}", smi_path.display()))?;
let smi_data: serde_json::Value = serde_json::from_str(&smi_raw)?;
let state_smi = smi_data.get(jurisdiction)
.with_context(|| format!("no SMI data for jurisdiction '{jurisdiction}'"))?;
let mut smi_by_hh_size = HashMap::new();
for size in 1..=6u32 {
let val = state_smi.get(&size.to_string())
.and_then(|v| v.as_f64())
.with_context(|| format!("missing SMI for HH size {size}"))?;
smi_by_hh_size.insert(size, Decimal::from_f64_retain(val).unwrap());
}
let smi_additional = state_smi.get("additional_person")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
// 2. Load jurisdiction.toml [caps] section
let jur_path = rulesets_dir.join(format!("{jurisdiction}/jurisdiction.toml"));
let jur_raw = std::fs::read_to_string(&jur_path)
.with_context(|| format!("reading {}", jur_path.display()))?;
let jur: toml::Value = toml::from_str(&jur_raw)?;
let caps = jur.get("caps")
.with_context(|| "missing [caps] section in jurisdiction.toml")?;
let initial_pct = caps.get("income_limit_initial_pct_smi")
.and_then(|v| v.as_integer()).unwrap_or(50) as u32;
let continued_pct = caps.get("income_limit_continued_pct_smi")
.and_then(|v| v.as_integer()).unwrap_or(85) as u32;
let min_hours = caps.get("min_work_hours_per_week")
.and_then(|v| v.as_integer()).unwrap_or(24) as u32;
let auth_months = caps.get("authorization_period_months")
.and_then(|v| v.as_integer()).unwrap_or(12) as u32;
// Copayment tiers from jurisdiction.toml (or defaults)
let copayment_tiers = if let Some(tiers) = caps.get("copayment_tiers").and_then(|v| v.as_array()) {
tiers.iter().filter_map(|row| {
let arr = row.as_array()?;
if arr.len() >= 3 {
Some((arr[0].as_integer()? as u32, arr[1].as_integer()? as u32, arr[2].as_integer()? as i32))
} else {
None
}
}).collect()
} else {
// Default Georgia CAPS copayment tiers
vec![(0, 25, 0), (26, 50, 2700), (51, 65, 4500), (66, 75, 6500), (76, 85, 8500)]
};
Ok(Self {
smi_by_hh_size,
smi_additional_person: Decimal::from_f64_retain(smi_additional).unwrap(),
initial_pct_smi: initial_pct,
continued_pct_smi: continued_pct,
min_work_hours_per_week: min_hours,
authorization_period_months: auth_months,
copayment_tiers,
})
}
/// Compute monthly SMI for a given household size.
pub fn monthly_smi(&self, household_size: u32) -> Decimal {
let annual = if household_size <= 6 {
self.smi_by_hh_size.get(&household_size).copied().unwrap_or_default()
} else {
let base = self.smi_by_hh_size.get(&6).copied().unwrap_or_default();
base + self.smi_additional_person * Decimal::from(household_size - 6)
};
annual / Decimal::from(12)
}
/// Compute income threshold for initial or continued eligibility.
/// threshold = monthly_smi * pct / 100
pub fn income_threshold(&self, household_size: u32, eligibility_type: &str) -> Decimal {
let pct = match eligibility_type {
"continued" => self.continued_pct_smi,
_ => self.initial_pct_smi,
};
self.monthly_smi(household_size) * Decimal::from(pct) / Decimal::from(100)
}
}
CapsRulesClient
Follow the same pattern as canopy-snap/src/rules_client.rs:
// services/canopy-caps/src/rules_client.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Client;
use uuid::Uuid;
use crate::errors::ApiError;
pub struct CapsRulesClient {
client: Client,
base_url: String,
}
impl CapsRulesClient {
pub fn new(base_url: &str) -> Self {
Self {
client: Client::new(),
base_url: base_url.to_string(),
}
}
pub async fn evaluate(
&self,
rule_set_name: &str,
context_type: &str,
context_id: Uuid,
input: serde_json::Value,
) -> Result<serde_json::Value, ApiError> {
let response = self.client
.post(format!("{}/v1/evaluate", self.base_url))
.json(&serde_json::json!({
"rule_set_name": rule_set_name,
"context_type": context_type,
"context_id": context_id,
"input": input,
}))
.send()
.await
.map_err(|e| ApiError::RulesEngine(format!("rules request failed: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::error!(status = %status, body = %body, "canopy-rules returned error");
return Err(ApiError::RulesEngine(format!("rules returned {status}")));
}
let result: serde_json::Value = response.json().await
.map_err(|e| ApiError::RulesEngine(format!("failed to parse rules response: {e}")))?;
result.get("output").cloned()
.ok_or_else(|| ApiError::RulesEngine("missing 'output' in rules response".to_string()))
}
}
Step 3: Store Layer (Models + CRUD)
Files: services/canopy-caps/src/store/mod.rs, services/canopy-caps/src/store/models.rs, services/canopy-caps/src/store/determinations.rs, services/canopy-caps/src/store/authorizations.rs
Domain models
// services/canopy-caps/src/store/models.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CapsDetermination {
pub id: Uuid,
pub application_id: Uuid,
pub household_id: Uuid,
pub child_person_id: Uuid,
pub determination_status: String,
pub income_eligible: bool,
pub activity_eligible: bool,
pub age_eligible: bool,
pub copayment_weekly_cents: Option<i32>,
pub authorized_weekly_hours: Option<i32>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub denial_reasons: Option<Vec<String>>,
pub ruleset_version: String,
pub jws_token: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CapsAuthorization {
pub id: Uuid,
pub determination_id: Uuid,
pub child_person_id: Uuid,
pub provider_id: String,
pub authorization_status: String,
pub weekly_hours: i32,
pub rate_cents_per_hour: i32,
pub copayment_weekly_cents: i32,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
CRUD functions
store/determinations.rs: create_determination, get_determination, list_determinations_by_household (with LIMIT/OFFSET).
store/authorizations.rs: create_authorization, get_authorization, list_authorizations_by_determination.
Wire store/mod.rs to re-export:
// services/canopy-caps/src/store/mod.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod authorizations;
pub mod determinations;
pub mod models;
All queries follow the sqlx::query_as::<_, Model>(SQL).bind(…).fetch_*() pattern used in canopy-snap and canopy-tanf.
Step 4: Income Eligibility via canopy-rules (50% SMI / 85% SMI)
Files: services/canopy-caps/src/eligibility.rs
Evaluate income eligibility by calling canopy-rules with the {jurisdiction}-caps-eligibility ruleset. The ruleset compares household gross monthly income against the SMI threshold.
// services/canopy-caps/src/eligibility.rs (income evaluation portion)
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::errors::ApiError;
use crate::rules_client::CapsRulesClient;
/// Application context received from canopy-eligibility.
#[derive(Debug, Clone, Deserialize)]
pub struct CapsApplicationContext {
pub application_id: Uuid,
pub household_id: Uuid,
pub child_person_id: Uuid,
pub child_age_years: u32,
pub child_has_special_needs: bool,
pub household_size: u32,
pub income: Vec<IncomeRecord>,
pub activity: ActivityContext,
pub provider_id: String,
pub jurisdiction: String,
pub eligibility_type: String, // "initial" or "continued"
}
#[derive(Debug, Clone, Deserialize)]
pub struct IncomeRecord {
pub source: String,
pub amount: Decimal,
pub frequency: String, // monthly, biweekly, weekly, annual
}
impl IncomeRecord {
pub fn monthly_amount(&self) -> Decimal {
match self.frequency.as_str() {
"monthly" => self.amount,
"biweekly" => self.amount * Decimal::from(26) / Decimal::from(12),
"weekly" => self.amount * Decimal::from(52) / Decimal::from(12),
"annual" => self.amount / Decimal::from(12),
_ => self.amount,
}
}
}
/// Income eligibility result returned by canopy-rules.
#[derive(Debug, Clone, Deserialize)]
pub struct IncomeEligibilityResult {
pub eligible: bool,
pub smi_threshold_monthly_cents: i64,
pub household_income_monthly_cents: i64,
pub eligibility_type: String,
pub basis: String,
}
JSON request sent to canopy-rules:
{
"rule_set_name": "georgia-caps-eligibility",
"context_type": "application",
"context_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"input": {
"gross_monthly_income": 2400.00,
"household_size": 4,
"eligibility_type": "initial"
}
}
JSON response from canopy-rules:
{
"rule_set_name": "georgia-caps-eligibility",
"output": {
"eligible": true,
"smi_threshold_monthly_cents": 298375,
"household_income_monthly_cents": 240000,
"eligibility_type": "initial",
"basis": "Household income $2,400/mo is below 50% SMI threshold of $2,983.75/mo for household size 4."
},
"evaluated_at": "2026-04-01T14:00:00Z"
}
Error handling:
-
reqwest::Error(connection refused, timeout) maps toApiError::RulesEngine. The determination recordsdetermination_status: "error"rather than failing silently. -
Non-2xx responses from canopy-rules (e.g., 404 for unknown ruleset) are logged at
errorlevel with the status code and response body.
Step 5: Activity Requirements Verification
Files: services/canopy-caps/src/activity.rs
Activity requirement evaluation is a local, pure function — no rules engine call. The caseworker has verified the activity; the system only checks the boolean gate and hour threshold.
// services/canopy-caps/src/activity.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
/// Activity context from the application intake.
/// The activity_verified flag is set by the caseworker -- not rules-computed.
#[derive(Debug, Clone, Deserialize)]
pub struct ActivityContext {
pub activities: Vec<Activity>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ActivityType {
Employment,
Education,
JobTraining,
CommunityService,
JobSearch,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Activity {
pub activity_type: ActivityType,
pub weekly_hours: u32,
pub verified: bool,
pub verification_source: Option<String>,
}
/// Result of activity requirement evaluation.
#[derive(Debug, Clone, Serialize)]
pub struct ActivityEligibilityResult {
pub eligible: bool,
pub total_weekly_hours: u32,
pub required_weekly_hours: u32,
pub basis: String,
}
/// Evaluate whether the parent/caretaker meets the CAPS activity requirement.
/// Employment, education, and job training hours are summed and compared
/// against the minimum threshold from jurisdiction.toml (default: 24 hrs/week).
///
/// This is a pure function -- no I/O, no rules engine call.
pub fn evaluate_activity_eligibility(
activity_ctx: &ActivityContext,
min_weekly_hours: u32,
) -> ActivityEligibilityResult {
let total: u32 = activity_ctx.activities.iter()
.map(|a| a.weekly_hours)
.sum();
let eligible = total >= min_weekly_hours;
let basis = if eligible {
format!("Activity requirement met: {total} hours/week meets minimum {min_weekly_hours} hours/week.")
} else {
format!("Activity requirement not met: {total} hours/week below minimum {min_weekly_hours} hours/week.")
};
ActivityEligibilityResult {
eligible,
total_weekly_hours: total,
required_weekly_hours: min_weekly_hours,
basis,
}
}
Error handling: activity evaluation is deterministic and cannot fail at runtime. If activity_ctx.activities is empty, total_weekly_hours is 0 and eligible is false.
Step 6: Copayment Determination
Files: services/canopy-caps/src/copayment.rs
Copayments are flat dollar amounts looked up from the copayment tier table in CapsParameterTable. The lookup computes the family’s income as a percentage of SMI, then finds the matching tier row.
// services/canopy-caps/src/copayment.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
use crate::params::CapsParameterTable;
/// Copayment determination result.
#[derive(Debug, Clone)]
pub struct CopaymentResult {
pub weekly_copayment_cents: i32,
pub income_pct_smi: u32,
pub basis: String,
}
/// Determine the family copayment from the jurisdiction.toml tier table.
///
/// 1. Compute income as a percentage of SMI for the household size
/// 2. Find the tier row where min_pct <= income_pct <= max_pct
/// 3. Return the weekly_copay_cents from that row
///
/// If no tier matches (income above all tiers), return the highest tier's copayment.
/// Zero copayment is a valid result for the lowest income tier.
pub fn determine_copayment(
params: &CapsParameterTable,
household_size: u32,
gross_monthly_income: Decimal,
) -> CopaymentResult {
let monthly_smi = params.monthly_smi(household_size);
let income_pct = if monthly_smi > Decimal::ZERO {
(gross_monthly_income * Decimal::from(100) / monthly_smi)
.round()
.to_string()
.parse::<u32>()
.unwrap_or(0)
} else {
0
};
let mut copay = 0i32;
let mut matched_tier = false;
for &(min_pct, max_pct, weekly_cents) in ¶ms.copayment_tiers {
if income_pct >= min_pct && income_pct <= max_pct {
copay = weekly_cents;
matched_tier = true;
break;
}
copay = weekly_cents; // track last tier as fallback
}
let basis = if matched_tier {
format!("Income at {income_pct}% SMI falls in copayment tier. Weekly copayment: ${}.{:02}.",
copay / 100, copay % 100)
} else {
format!("Income at {income_pct}% SMI above all tiers. Maximum copayment: ${}.{:02}.",
copay / 100, copay % 100)
};
CopaymentResult {
weekly_copayment_cents: copay,
income_pct_smi: income_pct,
basis,
}
}
Error handling: copayment determination is a pure function and cannot fail. Zero copayment is valid (families below the lowest tier). If the tier table is empty (misconfigured jurisdiction.toml), returns 0 with a logged warning.
Step 7: Provider Authorization + Rate Lookup
Files: services/canopy-caps/src/store/authorizations.rs (CRUD from Step 3), services/canopy-caps/src/eligibility.rs (authorization creation)
Provider rates are market-rate based, looked up via canopy-rules with the {jurisdiction}-caps-provider-rates ruleset. The authorization period is loaded from CapsParameterTable.authorization_period_months (default: 12).
/// Build and persist a CAPS authorization for an eligible determination.
/// Authorization period from CapsParameterTable.authorization_period_months.
pub async fn create_caps_authorization(
db: &PgPool,
rules: &CapsRulesClient,
params: &CapsParameterTable,
determination: &CapsDetermination,
context: &CapsApplicationContext,
copayment_weekly_cents: i32,
) -> Result<CapsAuthorization, ApiError> {
// 1. Look up provider rate via canopy-rules
let rate_output = rules.evaluate(
&format!("{}-caps-provider-rates", context.jurisdiction),
"authorization",
determination.id,
serde_json::json!({
"provider_id": context.provider_id,
"child_age_years": context.child_age_years,
"care_type": if determination.authorized_weekly_hours.unwrap_or(0) >= 30 {
"full_time"
} else {
"part_time"
},
"provider_type": "center",
}),
).await?;
let rate_cents = rate_output.get("rate_cents_per_hour")
.and_then(|v| v.as_i64())
.ok_or_else(|| ApiError::RulesEngine(
"missing rate_cents_per_hour in provider rate response".to_string()
))? as i32;
// 2. Build authorization
let effective = determination.effective_date;
let end = effective + chrono::Months::new(params.authorization_period_months);
let auth = CapsAuthorization {
id: Uuid::new_v4(),
determination_id: determination.id,
child_person_id: determination.child_person_id,
provider_id: context.provider_id.clone(),
authorization_status: "active".to_string(),
weekly_hours: determination.authorized_weekly_hours.unwrap_or(0),
rate_cents_per_hour: rate_cents,
copayment_weekly_cents,
effective_date: effective,
end_date: Some(end),
created_at: Utc::now(),
updated_at: Utc::now(),
};
// 3. Persist
crate::store::authorizations::create_authorization(db, &auth)
.await
.map_err(|e| ApiError::Internal(format!("failed to persist authorization: {e}")))
}
Error handling: if the provider rate lookup fails, the determination still completes but the authorization is NOT created. The determination records authorized_weekly_hours as None and a follow-up authorization can be created when the rate lookup succeeds.
Step 8: Determination Orchestrator + JWS Signing per ADR-002
Files: services/canopy-caps/src/eligibility.rs
The evaluate_and_determine() function orchestrates the full CAPS determination flow:
/// Trait for signing CAPS determinations per ADR-002.
pub trait DeterminationSigner: Send + Sync {
fn sign(&self, payload: &[u8]) -> Result<String, anyhow::Error>;
}
/// Orchestrate the full CAPS determination:
/// 1. Evaluate income eligibility (Step 4): rules engine call
/// 2. Evaluate activity requirements (Step 5): local pure function
/// 3. Check age gate: child_age < 13 (or < 19 with special needs)
/// 4. If all pass: determine copayment (Step 6)
/// 5. Build CapsDetermination struct with denial_reasons if denied
/// 6. Sign the determination (JWS per ADR-002)
/// 7. Persist to caps_determinations (append-only, never UPDATE)
/// 8. Return persisted signed determination
pub async fn evaluate_and_determine(
db: &PgPool,
rules: &CapsRulesClient,
signer: &dyn DeterminationSigner,
params: &CapsParameterTable,
context: CapsApplicationContext,
) -> Result<CapsDetermination, ApiError> {
// 1. Income eligibility
let income_result = evaluate_income_eligibility(
rules, &context, &context.eligibility_type,
).await?;
// 2. Activity eligibility
let activity_result = evaluate_activity_eligibility(
&context.activity, params.min_work_hours_per_week,
);
// 3. Age gate
let age_limit = if context.child_has_special_needs { 19 } else { 13 };
let age_eligible = context.child_age_years < age_limit;
// Collect denial reasons
let mut denial_reasons = Vec::new();
if !income_result.eligible {
denial_reasons.push(income_result.basis.clone());
}
if !activity_result.eligible {
denial_reasons.push(activity_result.basis.clone());
}
if !age_eligible {
denial_reasons.push(format!(
"Child age {} exceeds limit of {} years.",
context.child_age_years, age_limit
));
}
let all_eligible = income_result.eligible && activity_result.eligible && age_eligible;
let status = if all_eligible { "approved" } else { "denied" };
// 4. Copayment (only if eligible)
let gross_monthly: Decimal = context.income.iter()
.map(|i| i.monthly_amount())
.sum();
let copayment = if all_eligible {
Some(determine_copayment(params, context.household_size, gross_monthly))
} else {
None
};
// 5. Build determination
let now = Utc::now();
let effective = now.date_naive();
let end = if all_eligible {
Some(effective + chrono::Months::new(params.authorization_period_months))
} else {
None
};
let mut determination = CapsDetermination {
id: Uuid::new_v4(),
application_id: context.application_id,
household_id: context.household_id,
child_person_id: context.child_person_id,
determination_status: status.to_string(),
income_eligible: income_result.eligible,
activity_eligible: activity_result.eligible,
age_eligible,
copayment_weekly_cents: copayment.as_ref().map(|c| c.weekly_copayment_cents),
authorized_weekly_hours: if all_eligible {
Some(activity_result.total_weekly_hours as i32)
} else { None },
effective_date: effective,
end_date: end,
denial_reasons: if denial_reasons.is_empty() { None } else { Some(denial_reasons) },
ruleset_version: format!("{}-caps-eligibility", context.jurisdiction),
jws_token: None,
created_at: now,
updated_at: now,
};
// 6. Sign -- unsigned determinations must never exist in the database
let canonical = serde_json::to_vec(&determination)
.map_err(|e| ApiError::Internal(format!("serialization failed: {e}")))?;
let jws = signer.sign(&canonical)
.map_err(|e| ApiError::Internal(format!("signing failed: {e}")))?;
determination.jws_token = Some(jws);
// 7. Persist (append-only)
let persisted = crate::store::determinations::create_determination(db, &determination)
.await
.map_err(|e| ApiError::Internal(format!("failed to persist determination: {e}")))?;
Ok(persisted)
}
Signing invariant: if signer.sign() fails, the determination is NOT persisted. The canonical JSON is produced via serde_json::to_vec (deterministic, no whitespace variation).
Step 9: API Handlers
Files: services/canopy-caps/src/api/mod.rs, services/canopy-caps/src/api/determinations.rs
POST /v1/determine
Receives a CapsApplicationContext from canopy-eligibility, calls evaluate_and_determine(), optionally creates an authorization (Step 7), publishes events (Step 10), and returns the signed determination.
GET /v1/determinations/{id}
Returns a single persisted determination by UUID. Returns 404 if not found.
// services/canopy-caps/src/api/determinations.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{extract::{Path, State}, Json};
use uuid::Uuid;
use crate::eligibility::{CapsApplicationContext, evaluate_and_determine, create_caps_authorization};
use crate::errors::ApiError;
use crate::events;
use crate::state::CapsState;
use crate::store;
/// POST /v1/determine
#[utoipa::path(post, path = "/v1/determine", tag = "caps")]
pub async fn post_determine(
State(state): State<CapsState>,
Json(context): Json<CapsApplicationContext>,
) -> Result<Json<serde_json::Value>, ApiError> {
let determination = evaluate_and_determine(
&state.db, &state.rules, state.signer.as_ref(),
&state.params, context.clone(),
).await?;
// Create authorization if eligible
let authorization = if determination.determination_status == "approved" {
match create_caps_authorization(
&state.db, &state.rules, &state.params,
&determination, &context,
determination.copayment_weekly_cents.unwrap_or(0),
).await {
Ok(auth) => Some(auth),
Err(e) => {
tracing::error!(error = %e, "failed to create authorization; determination still valid");
None
}
}
} else {
None
};
// Publish events (fire-and-forget with logging) -- Step 10
// ... (see Step 10)
Ok(Json(serde_json::json!({
"determination": determination,
"authorization": authorization,
})))
}
/// GET /v1/determinations/{id}
#[utoipa::path(get, path = "/v1/determinations/{id}", tag = "caps")]
pub async fn get_determination(
State(state): State<CapsState>,
Path(id): Path<Uuid>,
) -> Result<Json<store::models::CapsDetermination>, ApiError> {
let det = store::determinations::get_determination(&state.db, id)
.await
.map_err(|e| ApiError::Internal(format!("query failed: {e}")))?
.ok_or(ApiError::NotFound(format!("determination {id} not found")))?;
Ok(Json(det))
}
Wire routes in api/mod.rs:
pub fn router() -> Router<CapsState> {
Router::new()
.route("/v1/determine", post(determinations::post_determine))
.route("/v1/determinations/{id}", get(determinations::get_determination))
}
Step 10: Event Publishing
Files: services/canopy-caps/src/events.rs
Publish events to the canopy.events topic exchange via lapin. Per ADR-004, events contain only IDs and timestamps — never PHI or income data.
// services/canopy-caps/src/events.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, Utc};
use lapin::Channel;
use serde::Serialize;
use uuid::Uuid;
use crate::errors::ApiError;
const EXCHANGE: &str = "canopy.events";
#[derive(Debug, Serialize)]
pub struct DeterminationCompletedEvent {
pub determination_id: Uuid,
pub application_id: Uuid,
pub determination_status: String,
pub completed_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct AuthorizationCreatedEvent {
pub authorization_id: Uuid,
pub determination_id: Uuid,
pub child_person_id: Uuid,
pub created_at: DateTime<Utc>,
}
pub async fn publish_determination_completed(
channel: &Channel,
event: &DeterminationCompletedEvent,
) -> Result<(), ApiError> {
let payload = serde_json::to_vec(event)
.map_err(|e| ApiError::Internal(format!("event serialization failed: {e}")))?;
channel
.basic_publish(
EXCHANGE,
"caps.determined",
lapin::options::BasicPublishOptions::default(),
&payload,
lapin::BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2), // persistent
)
.await
.map_err(|e| ApiError::Internal(format!("failed to publish determination event: {e}")))?
.await
.map_err(|e| ApiError::Internal(format!("publisher confirm failed: {e}")))?;
Ok(())
}
pub async fn publish_authorization_created(
channel: &Channel,
event: &AuthorizationCreatedEvent,
) -> Result<(), ApiError> {
let payload = serde_json::to_vec(event)
.map_err(|e| ApiError::Internal(format!("event serialization failed: {e}")))?;
channel
.basic_publish(
EXCHANGE,
"caps.authorization_created",
lapin::options::BasicPublishOptions::default(),
&payload,
lapin::BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await
.map_err(|e| ApiError::Internal(format!("failed to publish authorization event: {e}")))?
.await
.map_err(|e| ApiError::Internal(format!("publisher confirm failed: {e}")))?;
Ok(())
}
Event publishing is fire-and-forget in the API handler — failures are logged at error level but do NOT fail the determination. The canopy-security wildcard subscriber (#) receives both events for audit logging.
Step 11: JDM Rulesets
Files: rulesets/georgia/caps-eligibility.json (update existing), rulesets/georgia/caps-copayment.json (new), rulesets/georgia/caps-provider-rates.json (new)
caps-eligibility.json
The ruleset evaluates income eligibility against SMI thresholds. Input: gross_monthly_income, household_size, eligibility_type. Output: eligible, smi_threshold_monthly_cents, household_income_monthly_cents, eligibility_type, basis.
The decision table uses SMI values from smi-2026.json (embedded as lookup rows for household sizes 1-6 + additional person). For eligibility_type: "initial", the threshold is 50% SMI. For "continued", 85% SMI.
Step 12: Integration Tests
Files: services/canopy-caps/tests/caps_tests.rs
Full-flow integration tests using a test database on postgres-caps and mock canopy-rules server (via wiremock). Each test starts a fresh database transaction that is rolled back after the test.
// services/canopy-caps/tests/caps_tests.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
/// 1. Happy path: family income below 50% SMI, 30 hrs/week employment,
/// child age 4 -- approved with copayment and authorization.
#[tokio::test]
async fn caps_determination_approved_initial_eligibility() { /* ... */ }
/// 2. Denial: family income exceeds 50% SMI for initial eligibility.
#[tokio::test]
async fn caps_determination_denied_over_income() { /* ... */ }
/// 3. Denial: parent has only 16 hrs/week (below 24-hour minimum).
#[tokio::test]
async fn caps_determination_denied_insufficient_activity() { /* ... */ }
/// 4. Denial: child is 14 years old without special needs (age limit 13).
#[tokio::test]
async fn caps_determination_denied_child_over_age() { /* ... */ }
/// 5. Approved: child is 16 with special needs (age limit 19).
#[tokio::test]
async fn caps_determination_approved_special_needs_child() { /* ... */ }
/// 6. Continued eligibility: income between 50% and 85% SMI approved
/// for continued but would be denied for initial.
#[tokio::test]
async fn caps_continued_eligibility_higher_smi_threshold() { /* ... */ }
/// 7. Copayment is zero for families at the lowest income tier.
#[tokio::test]
async fn caps_zero_copayment_lowest_tier() { /* ... */ }
/// 8. Authorization created with 12-month period and correct rate.
#[tokio::test]
async fn caps_authorization_created_with_correct_period() { /* ... */ }
/// 9. JWS signature on determination can be verified.
#[tokio::test]
async fn caps_determination_signature_verifies() { /* ... */ }
/// 10. Events contain only IDs and timestamps -- no PHI (ADR-004).
#[tokio::test]
async fn caps_events_contain_no_phi() { /* ... */ }
Test details:
-
Test 1: mock canopy-rules returns
income_eligible: true, copayment tier_2, provider rate 750 cents/hour. Assertdetermination_status == "approved", all three eligibility flags true,copayment_weekly_cents.is_some(),jws_token.is_some(),end_date~12 months from effective. -
Test 2: mock rules returns
income_eligible: false. Assertdetermination_status == "denied",copayment_weekly_cents.is_none(),denial_reasonscontains income basis. -
Test 3: mock rules returns
income_eligible: true, activity context with 16 hrs. Assertdetermination_status == "denied",!activity_eligible. -
Test 4: context with
child_age_years: 14,child_has_special_needs: false. Assert!age_eligible. -
Test 5: context with
child_age_years: 16,child_has_special_needs: true. Assertage_eligible. -
Test 6: mock rules with
eligibility_type: "continued", income above 50% SMI but below 85%. Asserteligible. -
Test 7: very low income context. Assert
copayment.weekly_copayment_cents == 0. -
Test 8: assert
authorization_status == "active",rate_cents_per_hour == 750,end_date~12 months. -
Test 9: extract
jws_token, reconstruct canonical payload, verify with public key. -
Test 10: capture events, assert
incomeandchild_namefields absent.
Error handling in tests: wiremock mock servers return canned JSON matching the canopy-rules contract. Each test uses sqlx::test or manual BEGIN/ROLLBACK for isolation.
Files Touched
| File | Change |
|---|---|
|
New: |
|
Modify: add runtime + dev dependencies (axum, sqlx, chrono, lapin, reqwest, rust_decimal, wiremock) |
|
Modify: uncomment migration runner, wire |
|
Modify: declare modules (params, rules_client, eligibility, activity, copayment, events, store, api, state, errors) |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: module declarations for |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: |
|
Modify: add |
|
Modify: wire routes, event publishing |
|
New: |
|
Modify: income/activity/age expressions with SMI thresholds |
|
New: copayment tier schedule decision table |
|
New: market-rate provider rates decision table |
|
New: 10 integration tests |
Verification
-
cargo nextest run -p canopy-caps— all tests pass -
cargo xtask dev reloadwith canopy-caps service -
Verify CAPS determination returns signed JWS per ADR-002
-
Verify income test evaluates against correct SMI thresholds (50% initial, 85% continued)
-
Verify activity requirement checks employment hours against 24-hour minimum
-
Verify copayment calculation matches tier table for family size and income
-
Verify age gate: child < 13 (standard), child < 19 (special needs)
-
Verify authorization period is 12 months from effective date
-
Verify no FTI, IEVS, or HIPAA-scoped data in events or determination payloads
Documentation Updates
-
.claude/docs/services.md— add caps_applications, caps_determinations, caps_authorizations tables; document CAPS API routes (POST /v1/determine, GET /v1/determinations/{id}); document events -
.claude/CLAUDE.md— update canopy-caps from "stub" to "implemented" with route count -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/caps-eligibility.adoc— update status table steps to COMPLETE