Plan: SNAP Self-Employment Standard Deduction (Issue #414)

On this page

Status

Step Description Status

1

New JDM ruleset rulesets/georgia/snap-self-employment-deduction.json. Follows the input. / context.thresholds. namespaced shape used by snap-eligibility.json and consumed by NamespacedEval in crates/canopy-rules-client/src/lib.rs. Per-applicant fields under input.* (income_source, gross_monthly_income, actual_business_expenses); the 40% factor surfaces under context.thresholds.standard_deduction_pct. Output: { deduction_amount: number, basis: "actual" | "standard_40_percent" }. Logic: standard = input.gross_monthly_income * context.thresholds.standard_deduction_pct / 100; chosen = max(input.actual_business_expenses, standard). Compiles under zen-engine 0.55 (verified by cargo xtask rules check).

Done (2026-05-11) — ruleset shipped with household-aggregated input shape (input.gross_self_employment_income, input.actual_business_expenses), pct stored as fraction (0.40, not 40), basis value "standard_percent" (not "standard_40_percent" — generic so a future pct change doesn’t break consumers parsing the basis string). Fixture at crates/canopy-test-lib/fixtures/rulesets/snap-self-employment-deduction.json validates against zen-engine in-process via cargo xtask rules check (13 fixtures, 0 failed).

2

Wire the existing [snap.self_employment] block from rulesets/georgia/jurisdiction.toml (standard_deduction_pct = 40, standard_deduction_enabled = true, already cited in citations.toml to PAMMS 3425 + 7 CFR 273.11(a)(2)) into the deduction pipeline. The values + citations are already present — this step plumbs them through DeductionParams in services/canopy-snap/src/params.rs into the deduction-calc path. Do not create duplicate config entries. cargo xtask policy audit must stay green per ADR-011.

Done (2026-05-11) — wired through SnapParameterTable (params.rs) and SnapParameters (determine.rs:154-159). Pct converted to fraction (Decimal::new(40, 0) / 100) at load time. Loader fails closed if either key is missing. cargo xtask policy audit stays green at 209/204 keys.

3

Determination wiring. Update services/canopy-snap/src/deductions.rs deduction-calc path: when the income source is self-employment, evaluate the new ruleset via canopy-rules-client using the 5-arg evaluate(&ruleset_name, "application", app_id.into(), rules_input, bearer_token) signature (see services/canopy-snap/src/determine.rs:355-363 for the canonical call shape post-#424). The returned deduction_amount substitutes for the actual-expenses value used today. The wired-through deduction must preserve byte-stability when the determination response is wrapped in SignableDetermination (crates/canopy-signing/src/envelope.rs).

Done (2026-05-11) — deviation: deductions.rs is dead code on the determination path (calculate_deductions is only called by its own tests). The actual eligibility ruleset (snap-eligibility.json) is the deduction calculator at runtime. Pre-processing instead happens upstream in determine.rs via a new crate::se_deduction::compute(…​) module: gross SE income gets replaced with gross - deduction BEFORE the main ruleset runs. self_employment_net-typed income rows pass through as-is (already net). Adds business_expense to the recognized expense types. Uses post-#424 5-arg RulesClient::evaluate via namespaced(&input, &thresholds). Byte-stability preserved — the deduction folds into gross_earned_income (existing Decimal field) before SignableDetermination is built.

4

Tests. 6 unit tests in services/canopy-snap/src/deductions.rs (or tests/): (a) actual > standard → actual chosen, (b) standard > actual → standard chosen, (c) actual = standard → actual chosen, (d) zero actual + nonzero gross → standard chosen and nonzero, (e) zero gross → zero deduction, (f) non-self-employed income → ruleset not invoked. 2 integration tests through SNAP determine endpoint covering an approved + denied case where the deduction choice flips the outcome.

Done (2026-05-11) — split coverage: the ruleset’s choice logic (max(actual, standard)) is tested via the zen-engine in-process fixture (crates/canopy-test-lib/fixtures/rulesets/snap-self-employment-deduction.json) that runs in cargo xtask rules check; the Rust aggregation logic is tested via 6 unit tests in se_deduction::tests (empty short-circuit, SE income summing excluding _net variant, business_expense summing, weekly-frequency monthly conversion, no-SE-yields-empty, zero-expenses-aggregator). Devstack-gated end-to-end integration test deferred — the existing snap_test.rs fixtures don’t have self-employment scenarios; adding them is a separate scoped task that would also extend the application-intake test fixture catalog. All 158 canopy-snap tests pass; 13/13 ruleset fixtures pass.

5

Docs. CHANGELOG entry under === Added. Update docs/modules/ROOT/pages/services/canopy-snap.adoc deduction-table reference. Update docs/modules/ROOT/pages/policy/citations.adoc (or wherever PAMMS-citation prose lives) with the 3425 reference. Plan moves to plans/archive/ post-merge.

Done (2026-05-11) — CHANGELOG entry, api/canopy-snap.adoc deduction-step prose updated. citations.adoc not touched (no policy-citation prose page on disk; the canonical citation lives in rulesets/georgia/citations.toml which already has the 3425 entries). Plan moves to archive via cargo xtask docs plan-archive.

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; the DeductionInput / DeductionResult pipeline that consumes business-expense values.

  • services/canopy-snap/src/params.rsDeductionParams struct (line 64); landing point for the wired-through standard_deduction_pct.

  • services/canopy-snap/src/determine.rs:355-363 — canonical 5-arg RulesClient::evaluate invocation pattern (post-#424 bearer-token forwarding).

  • rulesets/georgia/snap-eligibility.json — existing JDM ruleset; precedent for input. / context.thresholds. namespaced shape.

  • crates/canopy-rules-client/src/lib.rsNamespacedEval envelope (line 67) defining the namespaced input/threshold convention.

  • crates/canopy-signing/src/envelope.rsSignableDetermination envelope (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 from jurisdiction.toml through DeductionParams into deductions.rs.

  • Rules-client invocation in deductions.rs for self-employment income sources, using the 5-arg evaluate signature with bearer-token forwarding.

  • Byte-stability preservation through the SignableDetermination envelope.

  • 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.toml entry, 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) when input.income_source == "self_employment" and context.thresholds.standard_deduction_enabled == true; otherwise pass through actual.

Config + citations already exist (do not duplicate):

  • rulesets/georgia/jurisdiction.toml lines 93-97 — [snap.self_employment] block with standard_deduction_pct = 40 and standard_deduction_enabled = true.

  • rulesets/georgia/citations.toml lines ~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

rulesets/georgia/snap-self-employment-deduction.json

New JDM ruleset (namespaced input. / context.thresholds. shape)

services/canopy-snap/src/params.rs

Extend DeductionParams with a self-employment sub-struct sourced from the existing [snap.self_employment] jurisdiction block

services/canopy-snap/src/deductions.rs

Wire 5-arg rules-client invocation into the deduction-calc path; preserve Decimal precision through SignableDetermination

services/canopy-snap/src/deductions.rs (test module)

6 new unit tests

services/canopy-snap/tests/self_employment_deduction_test.rs (or extend an existing integration test)

2 integration tests

CHANGELOG.adoc

=== Added entry

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

Deduction-table extension

Verification

  1. cargo xtask rules check — new ruleset compiles under zen-engine 0.55.

  2. cargo xtask policy audit — citations.toml stays green (no new entries added; the existing PAMMS 3425 citations cover the wired-through values).

  3. cargo nextest run -p canopy-snap — unit tests pass.

  4. cargo xtask dev start && cargo nextest run -p canopy-snap --test self_employment_deduction_test --run-ignored only — integration tests pass.

  5. Manual smoke: POST /v1/snap/determine for a self-employed applicant with zero actual expenses; assert deduction = gross * 0.40 and the determination is Approved where it would have been Denied pre-MR. Confirm the SignableDetermination JWS verifies (byte-stability preserved).

  6. 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

Edit this page · default