Plan: CMD change-report pipeline — facts → order → signed re-determination (#575, epic &77)
On this page
- Status
- Context
- Design
- D1. Closed vocabularies (canopy-reference + contracts-persons) — #1503
- D2.
determination.requestedreshaped; the order substrate (canopy-eligibility) — #1504 - D3. Medicaid determination plumbing (mirror snap’s #1213 D-6, point for point) — #1505
- D4. The CMD subsystem (canopy-medicaid) — #1506
- D5. The BFF action (canopy-web) — #1507
- D6. What #575 does NOT claim (filed + linked, not absorbed) — in #1508
- Tests
- Delivery
- Verification
Extends the ADR-002 Amendment 1 async determination contract with its single-case Order arm (the D1 field list taken at its word); governed by ADR-004 (event allowlist), ADR-028 (signing ritual), and ADR-043 (the exchanged-bearer worker action). Enrollment apply-semantics stay #1133; the medicaid NOA route and the 10-day escalation workflow are filed, linked gaps — not silently absorbed (§D6). Architecture rulings (2026-08-18): no shortcuts, no fig leafs, no pre-1.0 compat; fact writes go through the BFF worker action.
Status
| Step | Description | Status |
|---|---|---|
0 |
Epic &77 + child issues #1503–#1508; #575 attached; this plan committed + nav-linked. |
Done (2026-08-18) — direct-to-main docs commit |
1 |
#1503 (MR-1) Closed vocabularies: |
Done (2026-08-18) — MR !1163, merge 860c3112; #1509 (TANF |
2 |
#1504 (MR-2) The order substrate: |
Done (2026-08-18) — MR !1164, merge ef6d02a7; deviations recorded in D2 (standalone always-on sweep, ORDER_MAX_ATTEMPTS=12, claim-token settle fence) |
3 |
#1505 (MR-3) Medicaid plumbing mirroring snap #1213 D-6: context
trigger/previous_determination_id, gated as_of, signed effective_date, row columns |
Done (2026-08-18) — MR !1165, merge c905c973; hardened in review (approval-only effective_date, validated+gated pin, supersession-fork 409 backstop) |
4 |
#1506 (MR-4) The CMD subsystem: lifecycle migration (state/deadline/legacy),
ingest reshape (202, typed event type, application_id, receiver-contract guard |
Done (2026-08-18) — MR !1166, merge aebe9591; recorded window: the worker CMD form is dark until step 5 lands (deliberate; both deploy together) |
5 |
#1507 (MR-5) The BFF two-step fact action: persons disability update |
Done (2026-08-18) — MR !1167, merge ad74fadf; hardened in review (identity-first guard ordering, parse-before-write, ssi_recipient allowlist) |
6 |
#1508 (MR-6) Flip-test e2e suite, ADR-002 Order amendment, CHANGELOG, Antora, compliance annotation, gap filings (medicaid NOA, escalation workflow, /relate #1133); #575 closes. |
Done (2026-08-18) — this MR; flip test green live (baseline SSI approval → change-report DENIAL w/ supersession linkage, settled in ~2s); gaps filed #1510 (NOA) + #1511 (escalation) |
Epic: &77
Anchor issue: #575 (type::bug, priority::medium, T1)
Children: #1503 (w2) · #1504 (w5) · #1505 (w3) · #1506 (w5) · #1507 (w3) · #1508 (w3)
Context
The bug. POST /v1/cmd/ingest records a medicaid_cmd_events row and nothing ever
happens: no fact changes, no re-determination, processed_at NULL forever, a banner tells
the worker to click Run Determination. External review established the deeper truth: even
wiring a re-run would be inert — the SSI COA keys solely on
persons.disability_status == "ssi_recipient" (rulesets/georgia/medicaid-non-magi.json:31),
that column is free TEXT no worker surface can edit, SOLQ’s ssi_active is never consulted
for eligibility, and a naive MQ-consumer-calls-determine loop mints duplicate signed
determinations on retry.
The architecture.
-
Facts change first, through their owner. The worker-portal CMD action becomes a real two-step: canopy-web updates the person’s
disability_statusvia canopy-persons (worker-attributed, program-scope + household-membership IDOR guards — the income-editor precedent), THEN records the CMD event in medicaid.disability_statusbecomes a closed, validated vocabulary. -
The re-determination rides ADR-002’s async contract, taken at its word.
determination.requestedis reshaped to the Amendment 1 D1 field list — typed subject
programs + pinnedas_of+ signed trigger + idempotency-derivation identity — as a tagged two-arm payload (Cohort= the #1213 shape;Order= single-case). Eligibility owns a durable order-execution substrate generalized from the bulk core (D4 deterministic key, D5 durable per-unit state, D6 bounded retry, D9 signedchange-reporttrigger
previous_determination_id). Medicaid publishes the order and consumesdetermination.completed(extended with an origin-correlation slot) to settle its CMD row. Pure event choreography — no bespoke HTTP retry loop in medicaid. -
Honest boundary. #575 completes at "facts changed + signed re-determination linked
CMD row settled + 10-day clock visible/alarmed". The medicaid NOA route, enrollment supersession (#1133), and the escalation workflow are program-wide gaps existing for ALL medicaid determinations — filed and linked (§D6), with the compliance catalogue annotation updated to say exactly what is and is not covered.
Verified substrate (at HEAD): eligibility_requests one-pending-slot
idx_unique_pending_request (application_id, household_id) WHERE pending/in_progress
5-min stale sweep; DeterminationCompletedV1 {request_id, application_id, household_id,
programs_approved, programs_denied} staged atomically with completion (#1471), queue-bound
by NO domain consumer today; bulk_dispatch_idempotency_key
(crates/canopy-contracts-eligibility/src/bulk.rs:219) + the cohort CAS/ledger/settle
machinery (services/canopy-eligibility/src/bulk/store.rs); medicaid
ApplicationContext.as_of exists (TMA arithmetic; un-gated); the medicaid envelope signs
effective_date: None (determine.rs:1386) and never sets previous_determination_id;
snap’s trigger plumbing to mirror (context → envelope.trigger → row column w/ CHECK →
completed-event additive field); the persons fetch honors ?as_of=; medicaid is already an
ADR-043 receiver (#1426); BFF fact-editor guards deny_unless_in_scope
ensure_household_member (fact_editor.rs:25,98); the notices oldest-age gauge pattern
(canopy-notices/src/metrics.rs:121); ACLs: eligibility may publish
determination.(completed|requested), medicaid’s regex has neither
(devstack/rabbitmq/definitions.json:335,356).
Design
D1. Closed vocabularies (canopy-reference + contracts-persons) — #1503
-
DisabilityStatusenum in canopy-reference (IncomeType/AssetType template: serde + strum snake_case). Variants = the UNION of every value the tree actually compares:none,disabled,disabled_veteran,ssi_recipient, plus the non-MAGI comparison set found by the implementation-time sweep of all rules JSONs + code comparisons (ssdi_recipient,state_determined,not_disabled,blindexpected). Rule: no variant ships that nothing compares; no comparison survives that the enum lacks — pinned by a sweep test. Contracts-personsCreatePerson/UpdatePersonvalidate against it (thevalidate_ssn_digitscustom-validator wiring); thePersonread DTO keepsOption<String>on the wire; the write path refuses unknown values. Existing DB rows are inside the union. -
CmdEventTypeenum in canopy-reference. v1 vocabulary:ssi_terminated. Adding a variant later = defining its fact-write semantics — deliberate, not free. -
RequestOrigin { source: String, ref_id: Uuid }in canopy-contracts-eligibility — the requester-correlation identity for async orders (e.g.("medicaid-cmd", cmd_event_id)).
D2. determination.requested reshaped; the order substrate (canopy-eligibility) — #1504
Contract (pre-1.0 reshape, no compat arm): DeterminationRequestedV1 becomes a tagged
enum:
pub enum DeterminationRequestedV1 {
Cohort { run_id, case_id, dispatch_generation: i32, phase: CohortPhase }, // = today
Order { origin: RequestOrigin, application_id, household_id,
programs: Vec<String>, as_of: NaiveDate,
trigger: DeterminationTrigger, requested_by: String },
}
The Order arm IS the ADR-002 D1 field list. IDs + scalars only (ADR-004). CHANGELOG
Changed records the wire reshape; the bulk worker/consumer update in the same diff.
Durable order state — migration determination_orders:
CREATE TABLE determination_orders (
id UUID PRIMARY KEY CHECK (uuid_extract_version(id) = 7),
origin_source TEXT NOT NULL, origin_ref UUID NOT NULL,
application_id UUID NOT NULL, household_id UUID NOT NULL,
programs TEXT[] NOT NULL, as_of DATE NOT NULL,
trigger TEXT NOT NULL, requested_by TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN
('pending','dispatched','succeeded','failed_terminal','failed_retryable')),
program_epoch INT NOT NULL DEFAULT 1 CHECK (program_epoch >= 1),
attempt_count INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
attempt_started_at TIMESTAMPTZ,
eligibility_request_id UUID, last_error_code TEXT, last_error_excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (origin_source, origin_ref) -- the durable dedup identity
);
eligibility_requests gains origin_source TEXT, origin_ref UUID (paired-null CHECK,
index) so completion can carry the correlation.
Consumer generalization: the existing canopy-eligibility.bulk-redetermination
subscriber dispatches on the payload arm. Cohort → the untouched #1213 path. Order →
handle_order: upsert the order row (ON CONFLICT (origin_source, origin_ref) DO NOTHING
+ read-back — redelivery converges), CAS-claim (attempt_started_at IS NULL AND state IN
('pending','failed_retryable') AND next_attempt_at ⇐ now()), then execute through the
same in-service path the bulk arm proved: self-call POST /v1/eligibility/determine with a
deterministic idempotency key hash("canopy-order-dispatch-v1\0" ‖ origin ‖ program ‖
epoch) (D4; KAT-pinned sibling of bulk_dispatch_idempotency_key), requested_by from
the order, and the request row created with the origin columns. Retry semantics mirror the
bulk classification matrix: transient/5xx/409-slot-busy → failed_retryable + backoff
(60s doubling, 1h cap, jitter — deferred via next_attempt_at; the MQ delivery acks and a
1-minute order sweep in the existing worker re-stages due orders, so hot-retry exhaustion
cannot occur); other 4xx → failed_terminal (loud, DLQ-equivalent state, metric); a
non-definitive completed result → epoch bump + retry. Crash-after-determine recovery: the
24h idempotency middleware replays; the completed request row with matching origin is
adopted on the next attempt (result replay, not re-execution).
Orchestrator threading: the determine path accepts pinned as_of + trigger
previous_determination_id resolution for order-driven runs and threads them into context
assembly (fetch_household_context(?as_of=) already exists) and the medicaid context (D3).
stage_determination_completed extends DeterminationCompletedV1 with
origin: Option<RequestOrigin>.
MQ/ACL: medicaid’s topic-write regex gains determination.requested
(definitions.json:356); the topology lint stays clean (consumer exists first); the
production ACL step is recorded in the rollout runbook section.
D3. Medicaid determination plumbing (mirror snap’s #1213 D-6, point for point) — #1505
-
ApplicationContext(contracts-medicaid) gainstrigger: Option<DeterminationTrigger>andprevious_determination_id: Option<Uuid>(serde default + skip — wire-stable when absent);as_ofgains snap’s #1467 posture: a non-fallback value requires the exactcanopy-eligibilityidentity (403). -
Envelope: set
envelope.triggerandenvelope.previous_determination_id(both already signature-bound in canopy-signing) BEFORE signing;effective_date = Some(as_of)replaces the hardcodedNoneatdetermine.rs:1386— and the row write at:1612. -
Row: migration adds
trigger TEXT(kebab-case CHECK, snap’s20261121000000shape)
previous_determination_id UUIDtomedicaid_determinations, plus the snap-mirrored one-successor-per-prior partial unique (409supersession_conflictbackstop against concurrent-run chain forks); per-member prior resolution = latest determination row for(household_id, person_id)(per-member scoping is native —person_id NOT NULL). The explicit context pin is orchestrator-gated and validated (exists + same household) before signing. -
Event: the
medicaid.determinedpayload gainstriggeradditively. -
Full adjust-vs-supersede enforcement remains #1133 (linked); the signed linkage this MR adds is what #1133 consumes.
D4. The CMD subsystem (canopy-medicaid) — #1506
Migration — medicaid_cmd_events reshaped honestly (pre-1.0):
ALTER TABLE medicaid_cmd_events
ADD COLUMN application_id UUID, -- the worker-chosen operative application
ADD COLUMN state TEXT NOT NULL DEFAULT 'received' CHECK (state IN
('received','requested','completed','failed','legacy')),
ADD COLUMN deadline_at TIMESTAMPTZ, -- ingested_at + 10 days (PAMMS 2750)
ADD COLUMN determination_request_id UUID, ADD COLUMN failure_code TEXT;
UPDATE medicaid_cmd_events SET state = 'legacy' WHERE processed_at IS NULL; -- pre-subsystem rows, explicit
processed_at semantics corrected: stamped at state='completed' only (contract doc
updated; now() from the settling statement, not tx start).
Ingest reshaped (CmdIngestRequest: gains application_id: Uuid,
cmd_event_type: CmdEventType; drops submitted_by — spoofable prose): the guard moves
to the live receiver machinery — contract.require_service_or_exchanged(…)
EffectiveUser attribution (medicaid is receiver #1426; the future automated DCH feed is
the service arm, the worker action is the exchanged arm). Handler: one tx — insert CMD row
(state received, deadline_at) → flip to requested → stage
determination.requested::Order { origin: ("medicaid-cmd", cmd_event_id), application_id,
household_id, programs: ["medicaid"], as_of: effective_date, trigger: ChangeReport,
requested_by: EffectiveUser attribution } → commit. Response: 202 + the row (OpenAPI
regenerated — the current annotation doesn’t declare a success body).
Completion consumer: new group canopy-medicaid.cmd-settle bound to
determination.completed; skip events whose origin.source != "medicaid-cmd" (the first
domain consumer of this key; audit consumers unaffected). Settle: match origin.ref_id →
CMD row; medicaid ∈ approved∪denied ⇒ state='completed', processed_at=now(),
determination_request_id=request_id; else leave requested (the order substrate owns
retry). Since #1511 a TERMINAL order failure no longer stalls silently: the
canopy-medicaid.cmd-fail consumer marks the row state='failed' + failure_code
off the origin-echoing determination.order_failed.
Visibility: GET /v1/cmd/events?household_id= (service/exchanged read) listing rows with
state + deadline; GET /v1/cmd/escalations (#1511) feeding the worker-dashboard panel
(approaching-deadline + failed rows, [medicaid].cmd_escalation_warning_days window);
gauges canopy_medicaid_cmd_oldest_unresolved_age_seconds + depth + the (live since
#1511) _terminal_failed.
D5. The BFF action (canopy-web) — #1507
The CMD form (templates/cases/_det_action_form.html:259-269) becomes the two-step fact
action:
-
Fields: person select (household members),
cmd_event_typeselect (closed vocab — thecontact_methodpattern), effective date, the NEW disability-status select (what the person’s status becomes — forssi_terminatedthe worker picks the post-termination value, e.g.disabled/none), notes. -
Handler:
deny_unless_in_scope+ensure_household_member(IDOR) → typedUpdatePerson { disability_status }via persons (worker-attributed fact edit — the income-editor chain) → CMD ingest to medicaid under the exchanged bearer (the #1426 write-action pattern) with the case’sapplication_idfrom the page context. Partial failure: persons-update success + ingest failure ⇒ the error fragment tells the worker the fact saved but the change-report didn’t record — resubmit is idempotent-safe (a second identical persons update is a no-op COALESCE write; the second CMD ingest mints a new event id, so operators may see one duplicate order — acceptable: both settle on the same facts, the second supersedes with linkage; noted in the runbook). -
The redirect banner:
notice=cmd-queued— "SSI change recorded — an automatic re-determination is running; this tab updates when it lands" (the click-Run-Determination copy dies for this action). The medicaid determination tab renders CMD rows (state/deadline) from D4’s list endpoint.
D6. What #575 does NOT claim (filed + linked, not absorbed) — in #1508
| Gap | Disposition |
|---|---|
|
File |
Medicaid enrollment/supersession enforcement |
#1133 (exists) — D3’s signed linkage is its input; |
10-day escalation workflow (worker task on alarm) |
SHIPPED as #1511: terminal order failures map onto |
Compliance catalogue |
Annotate honestly: automated re-determination covered; notice-of-action NOT — cite the two filed issues |
Tests
| Test | Asserts |
|---|---|
e2e |
Seed household with |
e2e |
The same Order event published twice → exactly one new determination set; the order row converges; the CMD row settles once. |
e2e |
Unknown application_id → order |
integration: order sweep retry (eligibility) |
Transient program failure → |
unit/property |
|
existing |
The bulk e2e/AC6 suite stays green (the Cohort arm is semantically untouched);
|
Delivery
Each MR: fmt/clippy/targeted tests per commit, the full battery at push, merge on green per the standing authorization, close its issue with SHAs, update this plan’s Status table. Rollout ordering (runbook section in MR-2): broker ACLs → deploy eligibility (consumer) → deploy medicaid producer + web.
| MR | Branch | Content |
|---|---|---|
1 |
|
D1 — #1503 |
2 |
|
D2 — #1504 (bulk consumer adapted same-diff) |
3 |
|
D3 — #1505 |
4 |
|
D4 — #1506 (OpenAPI regen) |
5 |
|
D5 — #1507 |
6 |
|
Tests + ADR-002 Amendment (Order arm) + CHANGELOG |
Verification
-
The flip test is the acceptance proof: a seeded SSI household’s COA outcome CHANGES through the pipeline with signed trigger + linkage + pinned date.
-
Battery per MR;
mq-topology,compliance,quality-budgets, OpenAPI drift gates. -
Manual devstack: submit the CMD form; watch the tab flip received→requested→completed without any Run Determination click; check the oldest-age gauge and a forced-failure alarm path.