Plan: Eligibility Request Idempotency + Composition Graceful Degradation
On this page
This plan was authored from a code-grounded investigation, not from the issue text. Both issues describe the symptom correctly but prescribe the wrong cause/fix; the corrected reality is recorded in #588 — request idempotency. Read it before implementing — the issue bodies will mislead you.
-
#588 frames the bug as a "second-click race". It is not — the orchestrator’s
determine()is fully synchronous, so two HTTP calls cannot interleave the way the issue imagines. The real defect is an unconditional stale-row sweep plus a missingfailedtransition on the orchestrator’s error paths. -
#658 asks for "drop unknown plugins". It must be layer-aware: drop unknown slugs introduced by the role/user override layers, but keep the existing hard-fail for the jurisdiction baseline +
jurisdiction_livelayers (an existing test asserts the latter).
Status
| Step | Description | Status |
|---|---|---|
#588 — eligibility request idempotency |
||
1 |
canopy-eligibility store: TTL-gate the stale-row sweep; map the |
Done (2026-06-04) — TTL-gated sweep + |
2 |
canopy-eligibility orchestrator: extract |
Done (2026-06-04) — |
3 |
DB-backed integration tests: live-request conflict returns 409; retry-after-error succeeds (no stuck |
Done (2026-06-04) — |
#658 — composition graceful degradation |
||
4 |
canopy-composition loader: snapshot trusted (baseline + |
Done (2026-06-04) — Step 6 split + |
5 |
Tests: unknown role/user slug → dropped (surface still loads); unknown baseline/ |
Done (2026-06-04) — 2 loader tests + existing guardrail; warn! (no metrics crate in canopy-composition). |
shared |
||
6 |
Docs + CHANGELOG + GitLab issue updates (point #588/#658 at this plan). |
Done (2026-06-04) — CHANGELOG + plan status; issues updated on merge. |
Context
These are two unrelated correctness defects grouped into one plan because each is a small, well-scoped backend fix and both block clean operation of the worker portal under real (non-happy-path) conditions. They share no code.
#588 — Every POST /v1/eligibility/determine first runs an unconditional UPDATE … SET status='failed' WHERE status IN ('pending','in_progress') over the (application_id, household_id) pair before inserting the new request row (services/canopy-eligibility/src/store/mod.rs). The idx_unique_pending_request partial unique index permits at most one active request per pair, and the sweep exists to free that slot when a prior request was orphaned. But the sweep is indiscriminate: it also clobbers a genuinely live determination if a second request arrives while the first is still running, marking it failed even though it will go on to complete. The root cause of the orphans the sweep papers over is separate: the orchestrator marks a request in_progress and (on success) completed, but has no failed transition on any of its error/early-return paths, so any determination that errors leaves a stuck in_progress row forever.
#658 — The composition loader (crates/canopy-composition/src/loader.rs) resolves a 5-layer override stack (system defaults → jurisdiction baseline TOML → jurisdiction_live → role → user) into a dashboard or case-detail layout. Step 9 and Step 11 hard-fail with CompositionLoadError::UnknownPlugin (→ HTTP 500) the moment any item references a plugin slug that isn’t registered. That is correct for the trusted layers (a typo in the jurisdiction baseline should be loud), but for the user and role override layers it means one stale customization — e.g. a worker hid/kept a panel that was later renamed or removed from the build — takes the worker’s entire dashboard down with a 500. The surface should drop the unknown override item and render the rest.
This plan supports the SNAP UAT worker-portal reliability bar; neither fix changes an API contract.
Scope
In scope:
-
#588: TTL-gated sweep +
Conflict(409) on concurrent active request +failed-on-error in the orchestrator + DB integration tests. -
#658: layer-aware graceful degradation in the composition loader read path (drop unknown role/user slugs; keep hard-fail for baseline +
jurisdiction_live) + observability for dropped slugs + tests.
Out of scope:
-
Asynchronous/queued determinations (#588’s "race" framing assumes them; the orchestrator is and stays synchronous). No queue, no background worker.
-
Changing the
idx_unique_pending_requestindex definition or the request state machine beyond adding thefailed-on-error transition. -
Composition write-path validation changes —
PUT /v1/composition/{surface}/user/mealready rejects out-of-baseline slugs with 422 per ADR-024 (services/canopy-web/src/api/composition.rs). #658 is a read-path resilience fix for rows that became stale after a valid write (plugin later renamed/removed). The write path is unchanged. -
Any change to
canopy-rules, program services, or the JWS determination contract.
Design
#588 — request idempotency
Current code (verified against main):
-
create_eligibility_request—services/canopy-eligibility/src/store/mod.rs:10-59. Opens a txn, runs the unconditionalUPDATE … status='failed' WHERE status IN ('pending','in_progress')sweep (lines 28-40), thenINSERT … status='pending'(lines 42-55), then commits. Theidx_unique_pending_requestpartial index is the safety net the sweep is feeding. -
determine—services/canopy-eligibility/src/orchestrator.rs:568-1124. Callscreate_eligibility_request(574-582,map_err → ApiError::internal), marksin_progress(584-586), then runs the whole determination (588-1108), then markscompleted(1111-1113), then returnsOk(DetermineResponse)(1115-1123). Every?/return Err(…)between line 588 and line 1113 leaves the row stuckin_progress— there is nofailedtransition anywhere on an error path. Those stuck rows are exactly what the unconditional sweep was added to clear. -
determine()is part of the library contract —services/canopy-eligibility/src/lib.rsdocuments that integration tests callorchestrator::determinedirectly, so its signature must not change. -
ApiError::Conflict(String)already exists and maps to HTTP 409 (crates/canopy-common/src/error.rs:28,95).
The fix has two halves and both are required. TTL-gating the sweep without adding the failed-on-error transition would regress: a determination that errored would leave a stuck in_progress row that the now-TTL-gated sweep won’t clear until the TTL elapses, turning an immediate legitimate retry into a misleading 409.
Half 1 — store::create_eligibility_request
Change the sweep from unconditional to TTL-gated, and surface the unique-index collision as a typed conflict instead of pre-clearing live rows:
-
Add a stale-request TTL constant, generously larger than the maximum determination wall-clock (the orchestrator dispatches to program services in parallel under per-call timeouts/circuit-breakers; a determination completes in seconds). Use 5 minutes:
/// A `pending`/`in_progress` request older than this is presumed orphaned /// (process crash / panic mid-dispatch) and may be swept. Must exceed the /// maximum determination wall-clock so a genuinely live request is never /// reclassified as stale. See plan eligibility-and-composition-correctness. const STALE_REQUEST_TTL: chrono::Duration = chrono::Duration::minutes(5); -
Gate the sweep
UPDATEwithAND requested_at < $3where$3 = Utc::now() - STALE_REQUEST_TTL. A live concurrent request (younger than the TTL) is therefore left untouched. -
Keep the
INSERT. When a live request still holds the partial-index slot, theINSERTnow fails with a Postgres unique violation onidx_unique_pending_request. Return a typed error so the orchestrator can map it to 409. Two acceptable shapes:-
return
sqlx::Erroras today and letdetermine()inspect it (see Half 2), or -
change the return type to a small store-level error enum. Prefer the first (least churn, and
determine()already owns themap_err).
-
Keep the existing txn wrapper (sweep + insert stay atomic).
Half 2 — orchestrator::determine
-
Map the conflict. At the
create_eligibility_requestcall site (orchestrator.rs:574-582), replace the blanketmap_err(|e| ApiError::internal(…))with one that inspects the error: a unique violation onidx_unique_pending_requestbecomesApiError::Conflict("a determination for this household is already in progress".into()); anything else staysApiError::internal. Detect the constraint viasqlx::Error::Database(db_err)→db_err.constraint() == Some("idx_unique_pending_request")(sqlxDatabaseError::constraint). -
Add the
failed-on-error transition by extracting the determination body into a private helper so the wrapper can observe theResult:pub async fn determine( db: &PgPool, cfg: &DetermineConfig<'_>, request: DetermineRequest, ) -> Result<DetermineResponse, ApiError> { let elig_request = store::create_eligibility_request( db, request.application_id, request.household_id, &request.programs, &request.requested_by, ) .await .map_err(map_create_error)?; // <- unique-violation → Conflict store::update_request_status(db, elig_request.id, "in_progress") .await .map_err(|e| ApiError::internal("update status", e))?; let outcome = determine_inner(db, cfg, request, &elig_request).await; if outcome.is_err() { // Best-effort: a stuck `in_progress` row is exactly what the TTL // sweep was papering over. Failure to record `failed` must not // mask the real error, so log and swallow. if let Err(e) = store::update_request_status(db, elig_request.id, "failed").await { tracing::warn!(request_id = %elig_request.id, error = %e, "failed to mark eligibility request failed after determination error"); } } outcome } async fn determine_inner( db: &PgPool, cfg: &DetermineConfig<'_>, request: DetermineRequest, elig_request: &EligibilityRequest, ) -> Result<DetermineResponse, ApiError> { // verbatim body of the old determine(), lines 588-1123: // fetch_household_context … dispatch … create_combined_result … // update_request_status(..,"completed") … Ok(DetermineResponse { … }) }The extracted body is unchanged — same indentation level (it was already at fn-body indent), uses
requestby value andelig_request.id/&elig_requestexactly as before. The"completed"transition stays insidedetermine_inner(it only runs on the success path, which is correct). Do not reindent; this keeps the diff reviewable and thegit blamelegible.
in_progress update stays in the wrapper (before determine_inner) so that if it ever fails there is no half-created row to reconcile — create already succeeded, so a failure there returns ApiError::internal with the row left pending, which the TTL sweep reclaims. Putting in_progress inside determine_inner would also work but muddies the "wrapper owns lifecycle, inner owns work" split.
#658 — composition graceful degradation
Current code (verified against main):
-
load_composition—crates/canopy-composition/src/loader.rs:112+. Step 5 merges the jurisdiction baseline TOML (162-178). Step 6 (185-206) applies the DB layers in one loop injurisdiction_live → role → userorder (the SQL returns them ordered); for dashboard surfaces the user layer is deferred to Step 10.5, butjurisdiction_liveandrole(and, forcase_detail,user) are all applied here into theworkingJSON. Step 7 (209) deserializesworkingintoRawComposition; forcase_detail, Step 7’s tail (217-219) movesraw.sections→raw.items. -
Step 9 (230-253) iterates
raw.itemsand returnsUnknownPluginiffind_panel/find_case_sectionmisses. Step 11 (282-312) re-looks-up each item (ok_or_else → UnknownPlugin) to readallowed_spans. Both are unconditional hard-fails. -
Step 10 (256) is the role permission filter (silent-drop of items the role can’t see) — already graceful, unrelated.
-
ComposedItem/raw.itemscarry no layer provenance — once layers are merged you cannot tell which layer introduced a given slug. -
Existing contract test
loader_post_merge_unknown_plugin_rejects(crates/canopy-composition/tests/loader_test.rs:385-419) inserts an unknown slug into thejurisdiction_livelayer and assertsUnknownPlugin. This must keep passing — it is the guardrail that the trusted layers stay loud.
Design — snapshot the trusted slug-set, then drop unknown override slugs:
Because items lose layer provenance at merge time, classify by snapshotting the trusted slug-set before the untrusted layers are applied. The trusted layers are: system defaults + jurisdiction baseline TOML + jurisdiction_live. The untrusted layers are: role + user.
-
Split the Step 6 loop by layer. Apply only the
jurisdiction_live(and lower — defaults/baseline already inworkingfrom Steps 4-5) patches, then take a snapshot, then applyrole(and, forcase_detail,user) patches. Concretely: iteratedb_layers; forlayer.layer == CompositionLayer::JurisdictionLiveapply immediately; collectrole/userlayers into adeferredvec and apply them after the snapshot. (The dashboarduserlayer is already deferred to Step 10.5 — leave that exactly as-is.) -
Snapshot helper. After the
jurisdiction_livepatches are applied toworking, extract the set of item slugs intotrusted_slugs: HashSet<String>. Add a small helper that reads the slug list per surface from the JSON value — dashboards/sign-in readworking["items"],case_detailreadsworking["sections"](because thesections→itemsmove hasn’t happened yet at this point). Each entry’s slug is its"item"field.fn collect_item_slugs(working: &serde_json::Value, surface: ComposableSurface) -> HashSet<String> { let key = match surface { ComposableSurface::CaseDetail => "sections", _ => "items", }; working.get(key).and_then(|v| v.as_array()).into_iter().flatten() .filter_map(|item| item.get("item").and_then(|s| s.as_str()).map(str::to_owned)) .collect() } -
Step 9 — drop, don’t fail, for untrusted slugs. When
find_panel/find_case_sectionmisses:-
if the slug ∈
trusted_slugs→return Err(UnknownPlugin { slug })(unchanged — keeps the baseline/jurisdiction_liveguardrail and the existing test green); -
else (introduced by
role/user) → mark the item for removal and record it in adropped: Vec<String>.Collect-then-retain (don’t mutate
raw.itemswhile iterating): build the keep/drop decision, thenraw.items.retain(…). After the loop, if!dropped.is_empty(),tracing::warn!(surface = ?surface, role = %role.0, ?dropped, "dropped unknown override-layer composition items")and bump a counter metric (e.g.composition_items_dropped_total, labelsurface) so silent degradation is observable per the project’s "no silent caps" rule.
-
-
Step 11 — operate on survivors only. Because Step 9 already removed unknown untrusted items, Step 11’s
find_panel/find_case_sectionlookups can only miss on a trusted slug, so its existingok_or_else(UnknownPlugin)is now correct as-is for the trusted case. Keep it. (Belt-and-suspenders: a trusted unknown slug that slipped past Step 9 still hard-fails here, which is the desired loud behavior.) -
Ordering note —
case_detailuser layer. Forcase_detailthe user layer is applied in the (now-split) Step 6 after the snapshot, so a useraddof an unknown slug is correctly classified untrusted and dropped. For dashboards the user layer isuser_delta_v1(Step 10.5), which can only hide/reorder/respan existing baseline panels — it cannot introduce a new slug — so no additional handling is needed there.
Observability: a dropped override item is a real (if benign) signal that a worker/role customization has gone stale. The warn! + counter is the surface for an operator to notice and re-run Studio cleanup. Do not drop silently.
Steps
Step 1: TTL-gated sweep + typed conflict (#588 store)
Files: services/canopy-eligibility/src/store/mod.rs
Add STALE_REQUEST_TTL const. Gate the sweep UPDATE with AND requested_at < (now - TTL) (bind Utc::now() - STALE_REQUEST_TTL). Keep the txn + INSERT. Leave the return type as Result<EligibilityRequest, sqlx::Error> so the unique violation propagates to the orchestrator. Update the doc-comment to describe TTL-gating + the 409 contract.
Step 2: determine_inner extraction + failed-on-error (#588 orchestrator)
Files: services/canopy-eligibility/src/orchestrator.rs
Add fn map_create_error(e: sqlx::Error) → ApiError mapping the idx_unique_pending_request constraint violation → Conflict, else internal. Extract lines 588-1123 into async fn determine_inner(db, cfg, request, elig_request); rewrite determine() per the snippet in #588 — request idempotency. Do not change determine()’s public signature. Best-effort `failed transition logs-and-swallows on its own error.
Step 3: #588 DB integration tests
Files: services/canopy-eligibility/tests/ (add or extend the orchestrator/store integration test module; follow the existing #[sqlx::test]/devstack-pool pattern in that directory)
Cover:
-
Concurrent active request → 409: create a request, leave it
in_progress, callcreate_eligibility_requestagain for the same pair within the TTL → expect the unique violation; viadetermine(ormap_create_error) →ApiError::Conflict. -
Retry after error → success: simulate a determination that left a row
in_progress, advance past the TTL (insert with a back-datedrequested_at), then a newcreate_eligibility_requestsweeps it and succeeds. -
failed-on-error: drivedetermine()down an error path (e.g. unreachable persons URL viaDetermineConfig) and assert the request row endsfailed, notin_progress.
Step 4: layer-aware graceful degradation (#658 loader)
Files: crates/canopy-composition/src/loader.rs
Split the Step 6 DB-layer loop (apply jurisdiction_live, snapshot trusted_slugs, apply role/case_detail-user). Add collect_item_slugs. Rewrite Step 9 to drop untrusted unknown slugs (retain + dropped vec) and keep the trusted hard-fail. Add the warn! + composition_items_dropped_total counter. Leave Step 10/10.5/11 logic intact.
Step 5: #658 tests
Files: crates/canopy-composition/tests/loader_test.rs
-
New: unknown slug in a
role-layer patch → surface loads, the unknown item is absent, other items present. -
New: unknown slug in a
user-layer patch (usecase_detailso the user layer is RFC-6902 and canadd) → dropped, surface loads. -
Unchanged:
loader_post_merge_unknown_plugin_rejects(jurisdiction_live unknown) still assertsUnknownPlugin— confirm it passes untouched. -
Assert the dropped-item counter increments (if the test harness exposes the metrics registry; otherwise assert via the
warn!-path side effect / the survivor set).
Step 6: docs + issue updates
-
CHANGELOG.adoc— one entry under== Unreleasedper fix. -
Antora: note the 409 contract on the eligibility determine endpoint page (
docs/modules/ROOT/pages/api/canopy-eligibility.adoc) and the graceful-degradation behavior on the composition page. -
Update GitLab #588 and #658 to reference
xref:plans/archive/eligibility-and-composition-correctness.adocand correct their wrong framing (link the Design NOTE).
Files Touched
| File | Change |
|---|---|
|
TTL-gate the sweep; |
|
|
|
Conflict / retry-after-error / failed-on-error DB tests. |
|
Trusted-slug snapshot; layer-aware drop in Step 9; dropped-item observability. |
|
Role/user unknown-slug drop tests; keep jurisdiction_live reject test. |
|
Changelog + 409/graceful-degradation docs. |
Verification
-
cargo nextest run -p canopy-eligibility -p canopy-composition --lib— unit tests pass. -
cargo xtask dev refreshthen the canopy-eligibility + canopy-composition integration tests against the devstack pool (CANOPY_PORT_POSTGRES_5432=<docker ps port> cargo test -p canopy-eligibility, likewise composition) — DB-backed conflict/retry/drop tests pass. -
cargo nextest run --workspace— no regressions. -
cargo xtask validate— fmt + clippy + docker build clean. -
Manual: two rapid
POST /v1/eligibility/determinefor one household → first 200, second 409 (not a clobbered first). A dashboard with a stale user-layer panel slug renders (minus the stale panel) instead of 500.
Documentation Updates
-
CHANGELOG.adoc— entries under== Unreleased. -
Antora
api/canopy-eligibility.adoc(409 contract) + composition page (graceful degradation). -
Service Catalog — only if the eligibility route table’s error-code column is maintained there.
-
GitLab #588 / #658 — link this plan, correct framing.