Plan: SNAP Self-Employment Standard Deduction (Issue #414)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
New JDM ruleset |
Done (2026-05-11) — ruleset shipped with household-aggregated input shape ( |
2 |
Wire the existing |
Done (2026-05-11) — wired through |
3 |
Determination wiring. Update |
Done (2026-05-11) — deviation: |
4 |
Tests. 6 unit tests in |
Done (2026-05-11) — split coverage: the ruleset’s choice logic (max(actual, standard)) is tested via the zen-engine in-process fixture ( |
5 |
Docs. CHANGELOG entry under |
Done (2026-05-11) — CHANGELOG entry, |
Issue: #414
Branch: feat/snap-self-employment-standard-deduction
Labels: type::feature, priority::medium, service::snap, program::snap, workflow::ready
Context
PAMMS 3425 (Georgia DFCS SNAP manual, citing 7 CFR 273.11(a)(2)) lets a self-employed SNAP applicant claim a 40% standard expense deduction in lieu of itemized actual costs. canopy-snap currently uses actual reported expenses only; if a self-employed applicant reports zero actual expenses (common for service workers without significant overhead), they’re penalized — gross income is treated as net, inflating their countable income and either denying the case or shrinking the benefit.
The fix is mechanically simple: at deduction time, take max(actual, gross * 0.40). The right place to encode it per ADR-003 (ruleset-as-data) is a JDM ruleset, not Rust. The 40% factor already lives in rulesets/georgia/jurisdiction.toml under [snap.self_employment] (standard_deduction_pct = 40, standard_deduction_enabled = true) and is already cited in rulesets/georgia/citations.toml (lines ~976-998) against PAMMS 3425 + 7 CFR 273.11(a)(2). This plan does not add new config — it wires the existing values through the deduction pipeline.
Code references
-
services/canopy-snap/src/deductions.rs(~675 LOC) — deduction-calc path; theDeductionInput/DeductionResultpipeline that consumes business-expense values. -
services/canopy-snap/src/params.rs—DeductionParamsstruct (line 64); landing point for the wired-throughstandard_deduction_pct. -
services/canopy-snap/src/determine.rs:355-363— canonical 5-argRulesClient::evaluateinvocation pattern (post-#424 bearer-token forwarding). -
rulesets/georgia/snap-eligibility.json— existing JDM ruleset; precedent forinput./context.thresholds.namespaced shape. -
crates/canopy-rules-client/src/lib.rs—NamespacedEvalenvelope (line 67) defining the namespaced input/threshold convention. -
crates/canopy-signing/src/envelope.rs—SignableDeterminationenvelope (line 66); deduction output must remain byte-stable through it. -
rulesets/georgia/jurisdiction.toml— existing[snap.self_employment]block (lines 93-97). -
rulesets/georgia/citations.toml— existing PAMMS 3425 citations (lines ~976-998). -
PAMMS 3425: Georgia DFCS SNAP Policy and Procedure Manual.
-
7 CFR 273.11(a)(2): SNAP federal regulation on self-employment cost-of-business deductions.
Scope
In scope:
-
New JDM ruleset for the 40% / actual choice using
input./context.thresholds.namespaced shape. -
Wiring the existing
[snap.self_employment]block fromjurisdiction.tomlthroughDeductionParamsintodeductions.rs. -
Rules-client invocation in
deductions.rsfor self-employment income sources, using the 5-argevaluatesignature with bearer-token forwarding. -
Byte-stability preservation through the
SignableDeterminationenvelope. -
Unit + integration tests.
Out of scope:
-
Other deduction types (medical, dependent care, shelter) — they have their own existing paths.
-
Multi-state / per-jurisdiction overrides of the 40% factor — federal statute permits state variation but Georgia uses 40%; if another jurisdiction lands a different value, that’s a
rulesets/{jurisdiction}/jurisdiction.tomlentry, not a code change here. -
Income-source classification — assumes
income_source == "self_employment"is reliably labeled at intake; classification accuracy is a canopy-applications concern.
Dependencies
-
No prerequisite plans. The ruleset infrastructure, the policy-citation pipeline, and the SNAP determine handler are all already in place.
Design
Ruleset I/O follows the namespaced input. / context.thresholds. convention used by every existing georgia ruleset (verified against rulesets/georgia/snap-eligibility.json and the NamespacedEval envelope in crates/canopy-rules-client/src/lib.rs:67). Per-applicant fields land under input., jurisdiction values under context.thresholds.. This is non-negotiable — the rules-client envelope serialises into that shape and any flat-input ruleset would fail evaluation.
Ruleset (JDM) shape (illustrative; final form follows the snap-eligibility.json decision-node pattern):
-
input.income_source: string -
input.gross_monthly_income: number -
input.actual_business_expenses: number -
context.thresholds.standard_deduction_pct: number(e.g.,40) -
context.thresholds.standard_deduction_enabled: boolean -
Output:
{ deduction_amount: number, basis: "actual" | "standard_40_percent" | "none" } -
Logic:
standard = input.gross_monthly_income * context.thresholds.standard_deduction_pct / 100; chosen = max(input.actual_business_expenses, standard)wheninput.income_source == "self_employment"andcontext.thresholds.standard_deduction_enabled == true; otherwise pass through actual.
Config + citations already exist (do not duplicate):
-
rulesets/georgia/jurisdiction.tomllines 93-97 —[snap.self_employment]block withstandard_deduction_pct = 40andstandard_deduction_enabled = true. -
rulesets/georgia/citations.tomllines ~976-998 — PAMMS 3425 + 7 CFR 273.11(a)(2) citations for both keys.
Step 2 plumbs these into DeductionParams (services/canopy-snap/src/params.rs:64); no new TOML entries are added.
deductions.rs wiring (sketch — uses the 5-arg RulesClient::evaluate signature from services/canopy-snap/src/determine.rs:355-363 post-#424):
let deduction = if income.source == IncomeSource::SelfEmployment
&& params.self_employment.standard_deduction_enabled
{
let rules_input = build_namespaced_eval(
SelfEmploymentInput {
income_source: "self_employment",
gross_monthly_income: income.gross_monthly,
actual_business_expenses: income
.actual_business_expenses
.unwrap_or(Decimal::ZERO),
},
SelfEmploymentThresholds {
standard_deduction_pct: params.self_employment.standard_deduction_pct,
standard_deduction_enabled: params.self_employment.standard_deduction_enabled,
},
)?;
let result = rules
.evaluate(
"snap-self-employment-deduction",
"application",
app_id.into(),
rules_input,
bearer_token,
)
.await?;
parse_deduction_amount(&result)
} else {
income.actual_business_expenses.unwrap_or_default()
};
The deduction_amount flows into the determination response, which is wrapped in SignableDetermination (crates/canopy-signing/src/envelope.rs:66) before signing. SignableDetermination::canonical_signing_payload enforces byte-stability — the deduction value must round-trip through Decimal serialisation (no f64 path) so the canonical bytes stay identical across nodes and retries.
Files Touched
| File | Change |
|---|---|
|
New JDM ruleset (namespaced |
|
Extend |
|
Wire 5-arg rules-client invocation into the deduction-calc path; preserve |
|
6 new unit tests |
|
2 integration tests |
|
|
|
Deduction-table extension |
Verification
-
cargo xtask rules check— new ruleset compiles under zen-engine 0.55. -
cargo xtask policy audit— citations.toml stays green (no new entries added; the existing PAMMS 3425 citations cover the wired-through values). -
cargo nextest run -p canopy-snap— unit tests pass. -
cargo xtask dev start && cargo nextest run -p canopy-snap --test self_employment_deduction_test --run-ignored only— integration tests pass. -
Manual smoke: POST
/v1/snap/determinefor a self-employed applicant with zero actual expenses; assert deduction =gross * 0.40and the determination isApprovedwhere it would have beenDeniedpre-MR. Confirm theSignableDeterminationJWS verifies (byte-stability preserved). -
cargo xtask validate— full battery green.
Documentation Updates
-
CHANGELOG.adoc— entry under== Unreleased/=== Added -
docs/modules/ROOT/pages/services/canopy-snap.adoc— deduction-table row -
docs/modules/ROOT/pages/policy/citations.adoc(or equivalent) — PAMMS 3425 reference -
Plan archive: move to
plans/archive/post-merge