Plan: Medicaid SSA Orchestrator Wiring (Issue #384)
On this page
Status
| Step | Description | Status |
|---|---|---|
0 |
New |
Done (2026-05-11) |
1 |
Orchestrator-side SOLQ client. Extend |
Done (2026-05-11) |
2 |
Pre-dispatch SOLQ enrichment + ApplicationContext wiring. After |
p |
p.eq_ignore_ascii_case("medicaid"))` AND |
"disabled_veteran"`, or institutional flag forwarded from canopy-applications). For each qualifying member fire |
Done (2026-05-11) |
3 |
NonMagiInput plumbing inside canopy-medicaid. Non-MAGI evaluation runs through |
Done (2026-05-11) |
4 (a) |
Tests, docs, plumbing wiring. 4 unit tests in |
Done (2026-05-11) |
4 (b) |
Real SSA SOLQ/BINDEX cutover. Replace |
Blocked (CMA execution — tracker: #384) |
Issue: #384
Branch: feat/medicaid-ssa-orchestrator-wiring
Labels: type::feature, priority::medium, service::eligibility, service::medicaid, service::verification, program::medicaid, compliance::cma, workflow::ready
Deliverable (a) landed on 2026-05-11; the only open row is step 4(b), which stays Blocked (CMA execution) per the plan footer. Plan archived once (a) ships.
Context
The archived medicaid-coa-phase-d-abd-fbr-ssa plan (predecessor) added the five Pickle/DAC/DW/Widow 60-64/Former SSI Disabled Child boolean flags to ApplicationContext and NonMagiInput and wired the corresponding match arms in eligible_fn / denial_reason_fn. It explicitly deferred the orchestrator-side data flow: the boolean flags are accepted by canopy-medicaid but the orchestrator never populates them, so every applicant evaluating against any of those COAs sees the default false and is denied for "no_ssi_loss_due_to_cola" / "not_disabled_adult_child" / etc. — even when they would qualify.
This plan completes the data flow by:
-
introducing a SOLQ surface in
canopy-verification(no SOLQ adapter exists today — only IEVS and SAVE), -
wiring `canopy-eligibility’s orchestrator to call that surface pre-dispatch for Medicaid requests,
-
plumbing the response into the existing
NonMagiInputfields thatcanopy-rulesalready reads via the JDMmedicaid-non-magiruleset.
The Phase E waiver/institutional flags (hospice_election, length_of_stay_days, is_child_disabled_at_home, in_foster_care, etc.) sit on NonMagiInput next to the Phase D flags and consume the same ssa_solq plumbing where the underlying signal is SSA-sourced (e.g., disability onset date for TEFRA confirmation). Non-SSA flags (hospice election filed via state workflow, length-of-stay from facility intake) remain populated from other inbound dispatch fields.
Today’s reality (verified against the tree on 2026-05-11)
-
services/canopy-verification/src/api/contains onlyievs.rsandsave.rs. Nossa.rs. No SOLQ endpoint. -
services/canopy-verification/src/containsnoop.rs(IEVS) andnoop_save.rs(SAVE). Nonoop_solq.rs. The closest existing structs areSsaSdxRecordandSsaBendexRecordonievs.rs:45,51, but those are scoped to the SNAP IEVS path under 7 USC §2025(e) per ADR-004 — they cannot be reused as a Medicaid-side data source without violating the legally-scoped data tenancy boundary. -
services/canopy-eligibility/src/has noclients/directory. The orchestrator dispatches viaProgramServiceRegistry+ reqwest directly (orchestrator.rs:400-468) and fetches household context inline atfetch_household_context(orchestrator.rs:71-218). The SOLQ fetcher follows the same inline convention. -
services/canopy-medicaid/src/determine.rs:46definesApplicationContext(the inbound dispatch payload). Lines 116-130 hold the Phase DOption<bool>fields that read asNonetoday. -
services/canopy-medicaid/src/rules_client.rs:115-145definesNonMagiInput. The five Phase D booleans (lost_ssi_due_to_cola,is_disabled_adult_child,is_disabled_widow,is_widow_60_64,lost_ssi_as_disabled_child) are at lines 131-135.evaluate_non_magi(input, thresholds, token)calls intoRulesClient::evaluate(rule_set_name, context_type, context_id, input, token)— the 5-arg signature post-#424. -
services/canopy-eligibility/src/orchestrator.rs:272-288defines the orchestrator-sideApplicationContext(the dispatch payload sent to each program service). This is the struct that grows the newssa_solqfield. -
crates/canopy-signing/src/envelope.rs:66+definesSignableDetermination, whoseprogram_extension: Option<serde_json::Value>slot (line 114) is the response envelope — what each program service emits back to the orchestrator (ADR-002, #387). SSA data must NOT enter that slot; it flows in the opposite direction.
Code references
-
services/canopy-eligibility/src/orchestrator.rs— dispatch path (fetch_household_context,ApplicationContext,determine). -
services/canopy-verification/src/noop.rs+noop_save.rs— adapter templates for the newnoop_solq.rs. -
services/canopy-verification/src/api/save.rs— endpoint template for the newapi/ssa.rs. -
services/canopy-verification/src/main.rs:40-51— adapter wiring site for the newSsaState. -
services/canopy-medicaid/src/determine.rs— non-MAGI dispatch +NonMagiInputassembly. -
services/canopy-medicaid/src/rules_client.rs—NonMagiInput,evaluate_non_magi. -
Archived: medicaid-coa-phase-d-abd-fbr-ssa.adoc — predecessor; established the boolean fields this plan now populates.
-
ADR-002 — orchestrator → program-service envelope contract.
-
ADR-004 — IEVS data is SNAP-only; SOLQ is Medicaid-scoped under the CMA.
-
ADR-019 — service-class token forwarding pattern used by the new SOLQ fetch.
Scope
In scope:
-
New
noop_solq.rs+api/ssa.rsincanopy-verificationwith a deterministic SOLQ surface for dev/UAT, modelled on the IEVS/SAVE pattern. -
SolqRecordstruct (incanopy-verification, exported for cross-service deserialisation incanopy-eligibility+canopy-medicaid). -
Pre-dispatch SOLQ enrichment in the orchestrator with per-request caching keyed by
person_id. -
ApplicationContext.ssa_solqfield on both the orchestrator-side and medicaid-side context structs. -
derive_abd_flags_from_solqhelper incanopy-medicaid::determinethat maps SOLQ records onto the existingNonMagiInputbooleans. -
Graceful degrade on SOLQ fetch failure (
Option<SolqRecord>::Noneflows through; existingfalsedefaults in the JDM ruleset preserve current behaviour). -
Unit + integration tests + Antora doc updates + CHANGELOG.
Out of scope:
-
Real SSA SOLQ/BINDEX transport implementation (deliverable b — blocked on CMA).
-
SOLQ result caching beyond per-request scope. A persistent SOLQ cache (24h freshness window per Pub 1075 §5.5.1) is a follow-on once the real adapter lands and we have audit-emission guarantees.
-
CMA audit-log entries beyond what
canopy-verification+canopy-securityalready emit for the existing IEVS / SAVE internal endpoints. -
Reusing the SNAP-scoped
SsaSdxRecord/SsaBendexRecordfromievs.rs— ADR-004 prohibits cross-program reuse without a separate legal authorisation; the SOLQ surface is a distinct API path with its own audit envelope.
Dependencies
-
Archived medicaid-coa-phase-d-abd-fbr-ssa (predecessor; not reopened — it shipped the boolean fields this plan now populates).
-
No blocking dependency on
medicaid-jdm-completion.adoc(#386); the non-MAGI JDM ruleset already reads the five SSA-linked booleans.
Design
As-built deviation (2026-05-11): SolqRequest and SolqRecord landed in crates/canopy-reference/src/types.rs, not in services/canopy-verification/src/solq.rs as originally sketched. The orchestrator (canopy-eligibility) and the consumer (canopy-medicaid) both deserialise the same wire shape; the cleanest way to share types across three crates without a thin canopy-verification-types shim — or violating ADR-001 by depending on another service’s lib surface — is the existing universally-available types crate. canopy-reference picked up a rust_decimal dep (previously chrono-only). The SolqAdapter trait stayed in services/canopy-verification/src/solq.rs (no cross-service consumer; only noop_solq.rs implements it); the file now re-exports the types from canopy-reference. Also as-built: derive_abd_flags_from_solq returns a new AbdSsaFlags struct rather than mutating five let bindings inline — the override-channel idiom (ctx.x.unwrap_or(solq_flags.x)) lands verbatim per the original sketch.
Step 0 sketch — SOLQ adapter surface
services/canopy-verification/src/solq.rs (new — types module, sibling to ievs.rs / save.rs):
//! SSA SOLQ/BINDEX adapter. Per ADR-004, SOLQ access is Medicaid-scoped
//! under the Computer Matching Agreement; raw responses never leave
//! canopy-medicaid's database. Distinct from the IEVS SSA SDX/BENDEX path
//! in `ievs.rs`, which is SNAP-only under 7 USC §2025(e).
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolqRequest {
pub ssn: String,
pub first_name: String,
pub last_name: String,
pub date_of_birth: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolqRecord {
/// SSI active at lookup time (SSI Medicaid COA gate).
pub ssi_active: bool,
pub monthly_ssi_amount: Option<Decimal>,
/// Lost SSI due to a Social Security COLA increase (Pickle, PAMMS 2120).
pub lost_ssi_due_to_cola_flag: bool,
/// SSA benefit category: "DAC" | "WIDOW" | "DISABLED_WIDOW" | "OASDI" | ...
pub benefit_category: Option<String>,
pub monthly_benefit_amount: Option<Decimal>,
pub disability_onset_date: Option<NaiveDate>,
/// Former SSI-disabled child redetermination outcome (Zebley / age-18).
pub lost_ssi_as_disabled_child_flag: bool,
}
pub trait SolqAdapter: Send + Sync {
fn query_solq(
&self,
req: &SolqRequest,
) -> impl std::future::Future<Output = anyhow::Result<Option<SolqRecord>>> + Send;
}
services/canopy-verification/src/noop_solq.rs (new — mirrors noop.rs):
//! NoopSolqAdapter — deterministic test data keyed by SSN suffix.
//! Suffix bands chosen to exercise each Phase D / Phase E COA branch.
#[cfg(feature = "noop-adapters")]
pub struct NoopSolqAdapter;
#[cfg(feature = "noop-adapters")]
impl SolqAdapter for NoopSolqAdapter {
async fn query_solq(&self, req: &SolqRequest) -> anyhow::Result<Option<SolqRecord>> {
// 00-19: no SSA record (returns None)
// 20-29: active SSI
// 30-39: Pickle — lost SSI due to COLA
// 40-49: DAC — disabled adult child
// 50-59: Disabled Widow 50-64
// 60-69: Widow 60-64 (non-disabled)
// 70-79: Former SSI disabled child (Zebley / age-18)
// 80-99: OASDI benefits, no SSI loss
// (implementation follows noop.rs's match-on-suffix shape)
}
}
services/canopy-verification/src/api/ssa.rs (new — mirrors api/save.rs):
//! Internal SOLQ verification endpoint.
//! Called by canopy-eligibility pre-dispatch for Medicaid requests.
//! Authentication: X-Service-Api-Key header.
pub struct SsaState<A: SolqAdapter> {
pub adapter: A,
pub api_key: String,
}
pub fn internal_routes<A: SolqAdapter + 'static>(state: Arc<SsaState<A>>) -> Router {
Router::new()
.route("/internal/v1/ssa/solq", post(handle_solq::<A>))
.with_state(state)
}
Register in services/canopy-verification/src/api/mod.rs (add pub mod ssa;) and wire in services/canopy-verification/src/main.rs next to the existing IEVS / SAVE blocks:
#[cfg(feature = "noop-adapters")]
let ssa_state = Arc::new(api::ssa::SsaState {
adapter: noop_solq::NoopSolqAdapter,
api_key: internal_api_key.clone(),
});
// ...
#[cfg(feature = "noop-adapters")]
{
router = router
.merge(api::ievs::internal_routes(ievs_state))
.merge(api::save::internal_routes(save_state))
.merge(api::ssa::internal_routes(ssa_state));
}
Step 1-2 sketch — Orchestrator-side fetch + dispatch enrichment
// services/canopy-eligibility/src/orchestrator.rs
async fn fetch_ssa_solq(
client: &reqwest::Client,
verification_base_url: &str,
person: &MemberContext,
service_token: &ServiceTokenSource,
) -> Option<SolqRecord> {
if !needs_solq_for(person) {
return None;
}
let svc_jwt = service_token.current().await.ok()?;
let url = format!("{verification_base_url}/internal/v1/ssa/solq");
let resp = client
.post(&url)
.with_service_identity(&svc_jwt)
.header("x-service-api-key", /* injected from secrets */)
.timeout(std::time::Duration::from_secs(5))
.json(&SolqHttpRequest { /* ssn, name, dob from person */ })
.send()
.await
.ok()?;
if !resp.status().is_success() {
tracing::warn!(
person_id = %person.person_id,
status = %resp.status(),
"SOLQ fetch failed; degrading to None"
);
return None;
}
resp.json::<SolqRecord>().await.ok()
}
fn needs_solq_for(member: &MemberContext) -> bool {
member.age.map(|a| a >= 65).unwrap_or(false)
|| matches!(
member.disability_status.as_deref(),
Some("disabled" | "disabled_veteran")
)
}
// Inside `determine` between fetch_household_context and the dispatch loop:
let ssa_solq: Option<HashMap<Uuid, SolqRecord>> = if request
.programs
.iter()
.any(|p| p.eq_ignore_ascii_case("medicaid"))
{
let mut map = HashMap::new();
for m in &member_contexts {
if let (Some(pid_str), Some(rec)) = (
Some(&m.person_id),
fetch_ssa_solq(cfg.client, cfg.verification_base_url, m, cfg.service_token).await,
) && let Ok(pid) = Uuid::parse_str(pid_str) {
map.insert(pid, rec);
}
}
if map.is_empty() { None } else { Some(map) }
} else {
None
};
The dispatch payload (ApplicationContext) grows:
#[derive(Debug, Clone, Serialize)]
pub struct ApplicationContext {
// ... existing fields ...
pub jurisdiction: String,
/// SSA SOLQ records keyed by `person_id`. Populated by the orchestrator
/// pre-dispatch for Medicaid requests against the canopy-verification
/// SOLQ surface. `None` when the request never asked for Medicaid or
/// when no member qualified for the SOLQ gate.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssa_solq: Option<HashMap<Uuid, SolqRecord>>,
}
The medicaid-side ApplicationContext in services/canopy-medicaid/src/determine.rs:46 gains the same field (and the SolqRecord type — either re-exported via a thin shared canopy-verification-types crate or duplicated as a #[serde]-compatible mirror struct; the rewrite picks one in the implementation MR).
Step 3 sketch — NonMagiInput derivation
// services/canopy-medicaid/src/determine.rs
fn derive_abd_flags_from_solq(
map: &Option<HashMap<Uuid, SolqRecord>>,
applicant: Uuid,
) -> AbdSsaFlags {
let Some(record) = map.as_ref().and_then(|m| m.get(&applicant)) else {
return AbdSsaFlags::default(); // all false
};
AbdSsaFlags {
lost_ssi_due_to_cola: record.lost_ssi_due_to_cola_flag,
is_disabled_adult_child: record
.benefit_category
.as_deref()
.is_some_and(|c| c == "DAC"),
is_disabled_widow: record
.benefit_category
.as_deref()
.is_some_and(|c| c == "DISABLED_WIDOW"),
is_widow_60_64: record
.benefit_category
.as_deref()
.is_some_and(|c| c == "WIDOW"),
lost_ssi_as_disabled_child: record.lost_ssi_as_disabled_child_flag,
}
}
// Replace today's:
// let lost_ssi_due_to_cola = ctx.lost_ssi_due_to_cola.unwrap_or(false);
// let is_disabled_adult_child = ctx.is_disabled_adult_child.unwrap_or(false);
// ...
// with:
let solq_flags = derive_abd_flags_from_solq(&ctx.ssa_solq, applicant_id);
let lost_ssi_due_to_cola =
ctx.lost_ssi_due_to_cola.unwrap_or(solq_flags.lost_ssi_due_to_cola);
let is_disabled_adult_child =
ctx.is_disabled_adult_child.unwrap_or(solq_flags.is_disabled_adult_child);
// ... etc. The pre-existing `Option<bool>` ApplicationContext fields stay
// as an override channel (test fixtures, manual worker overrides) and win
// when present; SOLQ derivation is the implicit default.
The derived values feed into the existing NonMagiInput (rules_client.rs:115-145); the medicaid-non-magi.json JDM ruleset is unchanged.
Envelope direction note
This plan touches the dispatch payload only — the orchestrator → program-service request body shaped by services/canopy-eligibility/src/orchestrator.rs::ApplicationContext and services/canopy-medicaid/src/determine.rs::ApplicationContext. The response envelope (canopy_signing::SignableDetermination with its program_extension: Option<serde_json::Value> slot, used by canopy-medicaid to ship assigned_coa / assigned_coa_track / denial_reason back to the orchestrator per #387 and ADR-002) is not modified — that slot flows in the opposite direction and carries program-specific output, not orchestrator-sourced input.
Files Touched
| File | Change |
|---|---|
|
|
|
|
|
|
|
Register |
|
Wire |
|
Add |
|
Thread the verification base URL from config into |
|
Surface the verification base URL (already present as a per-service URL; add to the orchestrator config struct if it is not yet there). |
|
Extend |
|
1 devstack integration test: Pickle happy path against NoopSolqAdapter. |
|
Regenerated via |
|
Document the new pre-dispatch step, internal SOLQ endpoint, and SOLQ-fed COAs. |
|
Flip Medicaid Phases D-E to operational against the Noop adapter; add a Blocked row for deliverable (b). |
|
|
Verification
-
cargo nextest run -p canopy-eligibility -p canopy-medicaid -p canopy-verification --lib— unit tests pass (including the newderive_abd_flags_from_solqcases and the orchestrator SOLQ fetcher cases). -
cargo xtask api-docs— OpenAPI snapshots regenerate clean forcanopy-eligibility+canopy-verification. -
cargo xtask dev start && cargo nextest run -p canopy-eligibility --test medicaid_ssa_solq_test --run-ignored only— devstack integration test green against the NoopSolqAdapter. -
cargo xtask docs plan-lint— Status vocabulary clean; theBlockedrow carries the#384tracker reference. -
Manual smoke: dispatch a Medicaid determination for a 67-year-old applicant with an SSN suffix that maps to "Pickle" in
NoopSolqAdapter. Confirm: (i) orchestrator log shows the SOLQ fetch, (ii) canopy-medicaid receivesssa_solqpopulated for the applicant, (iii) the CMD cascade recordspickle_eligible: trueand EE15 assigns Pickle. -
cargo xtask validate— full battery green (fmt + clippy + nextest + docker build).
Documentation Updates
-
CHANGELOG.adoc— entry under== Unreleased/=== Added. -
docs/modules/ROOT/pages/services/canopy-eligibility.adoc— note the SSA pre-dispatch step +verification_base_urlconfig. -
docs/modules/ROOT/pages/services/canopy-verification.adoc— document the new internal SOLQ endpoint + NoopSolqAdapter SSN-suffix table. -
docs/modules/ROOT/pages/services/canopy-medicaid.adoc— Phases D-E now data-flow-complete against the Noop adapter; Blocked on real-SSA CMA. -
docs/modules/ROOT/pages/roadmap.adoc— Tier 3 Medicaid row flip; new Tier 5/6 Blocked row tracking the CMA cutover. -
Plan archive: this plan moves to
plans/archive/once deliverable (a) is merged and step 4 (b) is the only remaining open row. The Blocked-on-CMA row keeps the tracker reference (#384) so the deferred work stays discoverable per ADR-013.