Plan: Determination Envelope Normalisation (Issue #387)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Define |
Not started |
2 |
Replace |
Not started |
3 |
canopy-snap migration. Build a |
Not started |
4 |
canopy-medicaid migration. |
Not started |
5 |
canopy-tanf migration. Same shape. |
Not started |
6 |
canopy-caps + canopy-wic migration. These don’t sign yet ( |
Not started |
7 |
Orchestrator EE15 propagation update. |
Not started |
8 |
Integration test |
Not started |
9 |
utoipa + OpenAPI sync. |
Not started |
10 |
ADR-007 CLI parity. |
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 |
Not started |
12 |
Docs sync. CHANGELOG entry under |
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— buildsMedicaidDeterminationwith two separateUtc::now()calls (lines 678, 680), no rescale onbenefit_amount, no truncate on timestamps. -
services/canopy-medicaid/src/store/mod.rs:126-…—create_determinationreturnsResult<(), sqlx::Error>and does not bindcreated_at(the column hasDEFAULT 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) andreplacen("<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 betweendetermined_atandcreated_at, store bindscreated_atexplicitly + usesRETURNING *.
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 |
Does a SNAP-only deployment require canopy-medicaid to be running? |
No. canopy-snap signs |
Does the orchestrator need to understand each program’s specific fields? |
No — that’s the current coupling. The new envelope has |
If a new program (e.g., LIHEAP) is added, what changes? |
Nothing in canopy-signing. The new program imports |
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:
-
SignableDeterminationenvelope incrates/canopy-signingwith 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 |
|---|---|---|---|
|
|
|
The trust-boundary path. ADR-002 trust contract changes shape here. |
|
|
unchanged — per-program struct stays for listing/status views |
Internal catalogue, not signed-trust path. |
|
|
unchanged |
Internal status view. |
|
flat JSON payload via |
unchanged |
Events use a hand-built flat shape, not a serialised determination. See |
Per-program DB tables ( |
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 ( |
independent table populated alongside determinations |
unchanged |
Operates on raw fields (SSN scrub, etc.), not on the wire envelope. |
canopy-cli |
parses per-program response |
parses |
Step 10 — ADR-007 parity. |
Dependencies
-
crates/canopy-signing/src/lib.rs— addsmod envelope+ re-exports. -
services/canopy-eligibility/src/orchestrator.rs—ProgramDeterminationResponsebecomes a re-export ofSignableDetermination; 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 |
|---|---|
|
New module — |
|
Re-export |
|
|
|
|
|
Build |
|
Flip return type to |
|
Real signer wiring + RETURNING * variant for the create paths. |
|
Deserialise |
|
New devstack-gated test covering all 5 programs. |
|
Regenerated OpenAPI snapshots (committed). |
|
|
|
Tier 5.7 row for the medicaid-orchestrator-ee15-wiring errata gets "Resolved" annotation. |
|
Errata flipped from open to resolved. |
|
This plan; moves to |
Verification
Per-step
-
cargo nextest run -p canopy-signing— new envelope unit tests pass. -
cargo nextest run -p canopy-snap -p canopy-tanf -p canopy-medicaid -p canopy-caps -p canopy-wic— per-service tests still pass. -
cargo xtask dev start && cargo nextest run --test envelope_roundtrip_test --run-ignored only— all 5 programs verify clean. -
cargo xtask validate— full battery green.
End-to-end
-
cargo xtask dev start. Wait for healthy. -
POST /v1/eligibility/determinewithprograms: ["medicaid"]for a known-eligible Pathways household — assertprograms_approvedcontains"medicaid"(today this isprograms_pendingwithsignature_quarantinedbasis). -
Inspect
combined_results.medicaid_assigned_group— should be"pathways"(not null). -
Repeat with
programs: ["snap", "tanf", "medicaid"]— all 3 inprograms_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
DeterminationSignerthread 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.