Plan: Determination Envelope Normalisation (Issue #387)

On this page

Status

Step Description Status

1

Define SignableDetermination in crates/canopy-signing/src/envelope.rs (new file, SPDX header). Shape: pub struct SignableDetermination { id: DeterminationId, program: Program, application_id: ApplicationId, household_id: HouseholdId, status: String, benefit_amount: Option<Decimal>, benefit_unit: Option<String>, effective_date: Option<NaiveDate>, expiration_date: Option<NaiveDate>, renewal_date: Option<NaiveDate>, basis: Option<String>, denial_reason_codes: Option<Vec<String>>, program_service_version: String, determined_at: DateTime<Utc>, signature: String, [serde(skip_serializing_if = "Option::is_none")] program_extension: Option<serde_json::Value> } with derives Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema. All Option fields use [serde(skip_serializing_if = "Option::is_none")] except signature (which is always serialised — empty string serialises as "" in JSON; that’s the byte-stable form expected by the orchestrator’s replacen("<sig>", "") heuristic). Constructor SignableDetermination::build(…​) enforces byte-stability: calls truncate_to_micros(now) on determined_at, calls benefit_amount.map(|d| d.rescale(2)). Move truncate_to_micros from services/canopy-snap/src/determine.rs:460 into this crate as canopy_signing::time::truncate_to_micros. 6 unit tests covering: (a) byte-roundtrip after rescale, (b) byte-roundtrip after truncate, (c) skip_serializing_if drops absent fields, (d) signature-empty case serialises as "", (e) program_extension JSON survives roundtrip, (f) chrono / decimal serde matching the workspace serde-str config (Cargo.toml:104).

Not started

2

Replace ProgramDeterminationResponse in services/canopy-eligibility/src/orchestrator.rs:240-…​ with a re-export of SignableDetermination. The orchestrator’s deserialisation logic stays as serde_json::from_slice + raw-bytes verification; only the type narrows. The assigned_coa field handling moves: orchestrator pulls it from det.program_extension.as_ref().and_then(|v| v.get("assigned_coa")) for the EE15 propagation. Quarantine band-aid stays in place — only its trigger flips from "always for medicaid" to "actually broken signature".

Not started

3

canopy-snap migration. Build a SignableDetermination (snap-specific fields under program_extension), sign it, persist the per-program internal SnapDetermination to DB, return Json(SignableDetermination) on the wire. SNAP’s existing truncate_to_micros + rescale(2) logic moves into SignableDetermination::build. The SnapDetermination struct stays for DB and internal queries (status views, reporting). Existing post_determine_signature_present_and_nonempty test still passes.

Not started

4

canopy-medicaid migration. MedicaidDetermination stays for DB. Wire response becomes SignableDetermination with assigned_coa, assigned_coa_track, fmap_rate, continuous_eligibility_end, denial_reason, person_id packed into program_extension. Two-Utc::now() bug fixed (single shared now value used for both determined_at and the in-memory created_at). Handler at services/canopy-medicaid/src/api/handlers.rs:46 flips return type from Json<MedicaidDetermination> to Json<SignableDetermination>.

Not started

5

canopy-tanf migration. Same shape. TanfDetermination stays for DB; wire is SignableDetermination with denial_reason_code etc. in program_extension.

Not started

6

canopy-caps + canopy-wic migration. These don’t sign yet (Result<(), …​> from create + no signer wiring). This step adds signing for both, using SignableDetermination from day one. devstack signing-key generation (xtask/src/devstack_guard.rs) already covers caps + wic from the #338 fix; just need the signer fallback.

Not started

7

Orchestrator EE15 propagation update. services/canopy-eligibility/src/orchestrator.rs:462 collects medicaid_assigned_group from det.program_extension.as_ref().and_then(|v| v.get("assigned_coa")).and_then(|v| v.as_str()).map(String::from). Quarantine path stays (defence in depth) but the test medicaid_ee15_assigned_group_propagates_through_orchestrator should now pass deterministically.

Not started

8

Integration test services/canopy-eligibility/tests/envelope_roundtrip_test.rs. For each of {snap, tanf, medicaid, caps, wic}, dispatch a determination through the orchestrator against the real devstack and assert: (a) determination lands in programs_approved (not programs_pending with quarantine basis), (b) signature roundtrip is byte-clean, (c) program_extension contents are accessible. Devstack-gated (#[ignore]’d, run via `--run-ignored only).

Not started

9

utoipa + OpenAPI sync. SignableDetermination derives utoipa::ToSchema (already in Step 1). Each program service’s ApiDoc (services/canopy-{snap,tanf,medicaid,caps,wic}/src/api/mod.rs) registers it via [openapi(components(schemas(SignableDetermination, …​)))]. Each [utoipa::path] decoration on the determine handler flips its responses(…​) body type from the per-program type to SignableDetermination. Run cargo xtask api-docs to regenerate docs/modules/ROOT/openapi/canopy-{program}.json snapshots — committed in this MR. The OpenAPI drift gate in xtask/src/cmd/validate.rs will fail pre-push if the snapshots aren’t refreshed.

Not started

10

ADR-007 CLI parity. tools/canopy-cli/src/commands/determine.rs (or wherever the determine subcommand lives) is currently per-program-typed. Update it to deserialise SignableDetermination and surface program_extension fields in human-readable form (e.g., medicaid: block shows assigned_coa from extension JSON; tanf: shows denial_reason_code). 2 unit tests covering the output formatting for an approved Medicaid + a denied TANF.

Not started

11

Determination history note. Pre-MR signed determinations in DB cannot be re-verified post-MR — they were signed against the per-program struct, not SignableDetermination. Pre-production environment, no migration needed: document in CHANGELOG that any pre-MR *_determinations row has signature semantics from the legacy contract. Post-MR rows verify against SignableDetermination. If a need arises later (e.g., audit replay for ATO evidence), file a one-off backfill script — not in scope here.

Not started

12

Docs sync. CHANGELOG entry under == Unreleased / === Fixed. Roadmap Tier 5.7 row for medicaid-orchestrator-ee15-wiring errata gets a "Resolved 2026-MM-DD" annotation. The CHANGELOG note from the EE15 MR ("preexisting Medicaid signature-verification byte mismatch that remains out of scope") flips. Plan moves to plans/archive/determination-envelope-normalisation.adoc post-merge.

Not started

Issue: #387
Branch: fix/determination-envelope-normalisation
Labels: type::fix, priority::high, program::cross-program, service::shared-crates, service::eligibility, service::medicaid, service::snap, service::tanf, service::caps, service::wic, workflow::ready

Context

ADR-002 (signed determinations as the trust boundary) is effectively unenforceable for canopy-medicaid because every Medicaid determination passing through the orchestrator gets quarantined as signature_quarantined. The medicaid_assigned_group propagation only happens inside the sig_verified branch — so the quarantine masks the bug instead of surfacing it.

The quarantine path was the band-aid that landed alongside the EE15 wiring. The errata at medicaid-orchestrator-ee15-wiring explicitly punted the durable fix to a follow-up plan named determination-envelope-normalisation.adoc. That plan was never filed — until now.

Code references

  • services/canopy-medicaid/src/determine.rs:670-688 — builds MedicaidDetermination with two separate Utc::now() calls (lines 678, 680), no rescale on benefit_amount, no truncate on timestamps.

  • services/canopy-medicaid/src/store/mod.rs:126-…​create_determination returns Result<(), sqlx::Error> and does not bind created_at (the column has DEFAULT now() in the migration). The handler returns the in-memory determination, not the DB-fetched one.

  • services/canopy-eligibility/src/orchestrator.rs:464-503 — verification path uses raw response bytes (r.bytes().await) and replacen("<sig>", "") to reconstruct the signing payload. This was the #338 fix for "Bug 5".

  • services/canopy-snap/src/determine.rs:393-446 — the working reference: truncate_to_micros(Utc::now()) shared between determined_at and created_at, store binds created_at explicitly + uses RETURNING *.

The byte-fragility chain

rust_decimal is configured workspace-wide with features = ["serde-str"] (Cargo.toml:104), so Decimal::from(298) serialises as "298" but Decimal after rescale(2) or after a DB NUMERIC(10,2) roundtrip becomes "298.00". Sign one, serve the other → verification fails.

ADR-005 implications of a shared SignableDetermination struct

A natural concern: does sharing a struct across program services force them to deploy together? No.

Question Answer

Does the struct introduce a runtime dep between program services?

No. It’s a pure data type. canopy-snap and canopy-medicaid already both depend on canopy-signing; the struct lives there.

Does a SNAP-only deployment require canopy-medicaid to be running?

No. canopy-snap signs SignableDetermination; canopy-eligibility verifies the same struct. Neither imports anything from canopy-medicaid.

Does the orchestrator need to understand each program’s specific fields?

No — that’s the current coupling. The new envelope has program_extension: Option<serde_json::Value> for opaque program-specific data. The orchestrator just verifies + forwards; only callers that care about program-specific fields (e.g., the EE15 hierarchy that needs Medicaid’s assigned_coa) parse the extension JSON.

If a new program (e.g., LIHEAP) is added, what changes?

Nothing in canopy-signing. The new program imports SignableDetermination, fills in its own extension JSON, signs it. Orchestrator verifies it without code changes.

Net effect: the shared struct reduces coupling. Today the orchestrator’s ProgramDeterminationResponse carries assigned_coa, medicaid_application_id, tanf_application_id, etc. — leaks of program-specific knowledge into the orchestration layer. The envelope normalisation moves all that into an opaque extension blob; the orchestrator only knows the universal fields.

Scope

In scope:

  • SignableDetermination envelope in crates/canopy-signing with byte-stable construction.

  • All 5 program services emit it; orchestrator verifies it.

  • EE15 propagation through program_extension.

  • canopy-caps + canopy-wic gain real signing (today they don’t sign at all).

  • CLI parity per ADR-007.

  • utoipa schema registration + OpenAPI snapshot regen.

Out of scope:

  • Async signing thread pool (premature; file as future issue).

  • Backfill or re-verification of pre-MR signed determinations (pre-production environment; legacy rows accepted as-is).

  • Service-account / client-credentials auth flow (separate concern, file when needed).

Wire-shape transition matrix

What changes vs. what stays:

Endpoint Pre-MR wire Post-MR wire Notes

POST /v1/determine (each program)

Json<<Program>Determination>

Json<SignableDetermination>

The trust-boundary path. ADR-002 trust contract changes shape here.

GET /v1/determinations (list)

Json<Vec[Program>Determination]

unchanged — per-program struct stays for listing/status views

Internal catalogue, not signed-trust path.

GET /v1/determinations/{id}

Json<<Program>Determination>

unchanged

Internal status view.

*.determined events (canopy-mq)

flat JSON payload via publish_*_determined helpers

unchanged

Events use a hand-built flat shape, not a serialised determination. See services/canopy-medicaid/src/events.rs:11-37.

Per-program DB tables (snap_determinations etc.)

sqlx::FromRow on per-program struct

unchanged

Internal storage, not on the trust boundary.

Subscriber payload parsing

reads flat fields from the event payload

unchanged

Subscribers consume events, not HTTP responses.

FTI audit hash chain (fti_audit_log, ADR-014)

independent table populated alongside determinations

unchanged

Operates on raw fields (SSN scrub, etc.), not on the wire envelope.

canopy-cli canopy determine

parses per-program response

parses SignableDetermination + program_extension

Step 10 — ADR-007 parity.

Dependencies

  • crates/canopy-signing/src/lib.rs — adds mod envelope + re-exports.

  • services/canopy-eligibility/src/orchestrator.rsProgramDeterminationResponse becomes a re-export of SignableDetermination; raw-bytes verification stays.

  • services/canopy-{snap,tanf,medicaid,caps,wic}/src/determine.rs — build + sign envelope at handler boundary.

  • services/canopy-{snap,tanf,medicaid,caps,wic}/src/api/{handlers,mod}.rs — flip wire response type + register utoipa schema.

  • services/canopy-{snap,tanf,medicaid,caps,wic}/src/store/{mod,determinations}.rs — caps + wic stores gain real signer fallback (no schema migration; existing tables already have the columns).

  • tools/canopy-cli/src/commands/determine.rs — CLI deserialises envelope + extension.

  • xtask::devstack_guard::ensure_signing_keys — already covers all 5 programs from the #338 fix.

No schema migrations. No new workspace dependencies.

Design

Why no public-API churn for callers

The orchestrator’s downstream consumers (canopy-portal, canopy-web, canopy-cli) interact with the orchestrator’s CombinedResult, not the program services' raw determination shape. So the wire-shape change is observable only to the orchestrator (which deserialises directly) and the CLI (which the plan also updates). Other services that subscribe to *.determined events use the flat hand-built event payload, which is independent of the wire response.

Byte-stability constructor

The byte-fragility bugs (Bug 6 from #338) were timestamp + decimal + DB-default mismatches. The SignableDetermination::build constructor enforces all three at construction time:

impl SignableDetermination {
    pub fn build(
        id: DeterminationId,
        program: Program,
        application_id: ApplicationId,
        household_id: HouseholdId,
        status: impl Into<String>,
        benefit_amount: Option<Decimal>,
        // ... rest of universal fields
        program_extension: Option<serde_json::Value>,
    ) -> Self {
        let now = canopy_signing::time::truncate_to_micros(Utc::now());
        Self {
            id,
            program,
            application_id,
            household_id,
            status: status.into(),
            benefit_amount: benefit_amount.map(|d| d.rescale(2)),
            // ...
            determined_at: now,
            signature: String::new(),
            program_extension,
        }
    }
}

Per-program callers fill the universal fields, drop program-specific fields into program_extension, sign the envelope, return it as the wire response.

Orchestrator EE15 propagation

// services/canopy-eligibility/src/orchestrator.rs (post-Step 7)
if program_enum == Program::Medicaid {
    medicaid_assigned_group = det
        .program_extension
        .as_ref()
        .and_then(|v| v.get("assigned_coa"))
        .and_then(|v| v.as_str())
        .map(String::from);
}

The quarantine path stays as defence-in-depth — but the assigned_coa lookup happens INSIDE the sig_verified branch only, so unverified determinations cannot leak into combined results.

Files Touched

File Change

crates/canopy-signing/src/envelope.rs

New module — SignableDetermination struct + build constructor + truncate_to_micros helper.

crates/canopy-signing/src/lib.rs

Re-export envelope::* and time::truncate_to_micros.

services/canopy-eligibility/src/orchestrator.rs

ProgramDeterminationResponse → re-export of SignableDetermination. EE15 propagation reads from program_extension.

services/canopy-eligibility/src/store/models.rs

CombinedResult.medicaid_assigned_group field unchanged; only the source path changes.

services/canopy-{snap,tanf,medicaid,caps,wic}/src/determine.rs

Build SignableDetermination at handler boundary (single now, rescaled decimals, program-specific data → extension).

services/canopy-{snap,tanf,medicaid,caps,wic}/src/api/{handlers,mod}.rs

Flip return type to Json<SignableDetermination>; register utoipa schema in ApiDoc.

services/canopy-{caps,wic}/src/store/mod.rs

Real signer wiring + RETURNING * variant for the create paths.

tools/canopy-cli/src/commands/determine.rs

Deserialise SignableDetermination; render program_extension per-program.

services/canopy-eligibility/tests/envelope_roundtrip_test.rs

New devstack-gated test covering all 5 programs.

docs/modules/ROOT/openapi/canopy-{snap,tanf,medicaid,caps,wic}.json

Regenerated OpenAPI snapshots (committed).

CHANGELOG.adoc

== Unreleased / === Fixed entry.

docs/modules/ROOT/pages/roadmap.adoc

Tier 5.7 row for the medicaid-orchestrator-ee15-wiring errata gets "Resolved" annotation.

docs/modules/ROOT/pages/plans/archive/medicaid-orchestrator-ee15-wiring.adoc

Errata flipped from open to resolved.

docs/modules/ROOT/pages/plans/determination-envelope-normalisation.adoc

This plan; moves to plans/archive/ post-merge.

Verification

Per-step

  1. cargo nextest run -p canopy-signing — new envelope unit tests pass.

  2. cargo nextest run -p canopy-snap -p canopy-tanf -p canopy-medicaid -p canopy-caps -p canopy-wic — per-service tests still pass.

  3. cargo xtask dev start && cargo nextest run --test envelope_roundtrip_test --run-ignored only — all 5 programs verify clean.

  4. cargo xtask validate — full battery green.

End-to-end

  1. cargo xtask dev start. Wait for healthy.

  2. POST /v1/eligibility/determine with programs: ["medicaid"] for a known-eligible Pathways household — assert programs_approved contains "medicaid" (today this is programs_pending with signature_quarantined basis).

  3. Inspect combined_results.medicaid_assigned_group — should be "pathways" (not null).

  4. Repeat with programs: ["snap", "tanf", "medicaid"] — all 3 in programs_approved.

Risk + Rollback

Risk: introducing a wire-schema change touches the orchestrator + 5 program services in one MR. If a serialisation edge case is missed, every program goes to quarantine simultaneously.
Mitigation: pre-production environment, no canary needed (per user direction 2026-04: "this app isn’t in production, there is no blast radius risk…​ it either passes pre-push or it doesn’t"). The envelope_roundtrip_test covers all 5 programs against devstack before merge; pre-push hook gates the regression.
Rollback: revert the MR. Per-program internal *Determination structs untouched; only the wire response shape changed.

Potential Improvements

(Out of scope; file separately if/when relevant.)

  • Async signing thread pool — today each service signs synchronously inside the request handler. For high QPS a dedicated DeterminationSigner thread pool would let the handler return faster. Premature.

  • Backfill script for pre-MR determinations — if a future ATO evidence cycle requires re-verifying historical determinations against the new envelope, write a one-off conversion script that reconstructs the legacy bytes for verification.

  • Field-level program_extension typing — today extensions are serde_json::Value. A per-program typed-extension struct (e.g., MedicaidExtension { assigned_coa, …​ }) would catch typos at compile time. Low value while only 1-2 callers per program parse the extension.

Errata

(none)

Edit this page · default