Plan: SSR aggregate request deadline + honest degraded states (#1306, epic &73)
On this page
- Status
- Context
- Design
- D1. Deadline type and the one-cutoff model
- D2. Threading — fields on the existing clone chain
- D3. Enforcement — read verbs only, one absolute cutoff, permit held through decode
- D4. Token acquisition — bounded, and honest downstream
- D5.
ServiceErroroverhaul — typed, safe by default, centrally classified - D6. Honest states — inventoried across every stamped surface
- D7. Manifest
timeout_msactivation — both dispatch paths - D8. Config — two knobs, bounded, overridable
- D9. Telemetry —
services/canopy-web/src/telemetry.rs, exporter-tested
- Inventory (verified 2026-08-04 against main @ bf6bbf79)
- Review resolutions (external review, rev 3)
- Delivery
- Test plan
- Verification
Design selected via a comparative multi-design review, then hardened by an external review whose blocking findings (absolute-cutoff model, permit-through-decode, guard race, write exclusion, honesty coverage, config bounds) are folded in as rev 3. Scope is narrowed on-issue (#1306) to the six covered read routes + machinery; everything else is filed as #1319–#1326.
Status
| Step | Description | Status |
|---|---|---|
0 |
Pre-implementation: follow-ups #1319–#1326 filed + related; #1306 AC narrowed on-issue; this plan committed + nav-linked. |
Done (2026-08-04) — this MR |
1 |
MR1 |
Done (2026-08-04) — !1071 (impl f6d19147, merge b61e72cd) |
2 |
MR2 |
Done (2026-08-04) — !1072 (impl 5ace7fb9, merge 1b213755) |
3 |
MR3 |
Done (2026-08-04) — this MR |
Epic: &73
Issue: #1306 (high)
Branches: feature/1306-ssr-deadline-mr1 → mr2 → mr3
Context
canopy-web SSR handlers block the HTML response on un-budgeted upstream fan-out. Only
the dashboard panel dispatcher sets any budget — per call, not per chain. Case-detail
sections ignore their manifest timeout_ms=5000. Serial phases accumulate unbounded
(my_queue’s 3 sources + ≤20-call name resolution; case-detail’s serial pre-phase,
section fan-out, then serial hero; the fully serial determination assembler). Token
acquisition (10s/15s internals) sits outside every budget. One hung upstream stalls the
page into the 15s e2e navigation ceiling.
Compounding it, upstream Err frequently renders as false success: my_queue drops
every source error and shows "All caught up"; the hero shows .unwrap_or(0);
?program=all collapses errors into "No determinations". The inventory below found
15 of 23 case-detail sections with at least one silently-collapsing leg. In a
benefits system that is dangerous, not merely wrong.
Requirements (issue #1306, reconciled spec): aggregate absolute deadline — not per-call
budgets; preserve completed partials (no abort-that-discards); distinct
timeout/partial/error/genuine-empty states; token acquisition inside the bound; activate
the existing manifest timeout_ms contract; shared fan-out ceiling; categorical
redaction-safe telemetry; handler-level fault tests; no pool change; never raise the
15s navigation budget.
Design
D1. Deadline type and the one-cutoff model
New services/canopy-web/src/deadline.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Clone, Copy, Debug)]
pub struct RequestDeadline { deadline: std::time::Instant }
-
from_now(budget)saturates at a hard 600s ceiling (nochecked_addpanic path even under the config override). -
All math in pure
now-parameterized functions (remaining_at(now),clamp_at(now, cap)) — proptested without wall clock; wall-clock methods are thin wrappers.std::time::Instantthroughout (same clock domain ascanopy_api::retry). -
MIN_CALL_FLOOR = 50ms: below the floor a call cannot succeed — skip it, typed.
The cutoff model: each logical call computes one absolute
call_deadline = min(page_deadline, verb_entry_now + component_cap) where
component_cap = call_timeout | CLIENT_DEFAULT_TIMEOUT (5s). The winning min arm is
recorded for DeadlineExceeded-vs-Timeout classification (page arm wins ties). Every
stage derives its residual from that same absolute point, recomputed at the boundary:
gate acquire, each retry attempt’s reqwest timeout, the retry policy’s overall bound,
and the body/decode reads. Time can never "reset" across queueing, retries, or the
headers→body seam.
Stamping: top-level read handlers only — get_dashboard, get_case_search,
search_cases, get_case_detail, get_tab, get_panel_fragment. Nested helpers
(composed-tab fallback, render_cross_program_summary, section renderers) inherit
via the clients and never re-stamp (a second stamp would mint a second gate —
forbidden, doc-commented). Stamp before identity:
clients.with_deadline(d).with_service_identity(&svc).await (order tested).
/cases and /cases/search gain the Extension<Arc<WebConfig>> they lack today.
D2. Threading — fields on the existing clone chain
-
InternalClient+=deadline: Option<RequestDeadline>,page_gate: Option<Arc<Semaphore>>,auth_unavailable: bool;ServiceClients+= mirrordeadlinefield. -
ServiceClients::with_deadline(d)clones all 15 clients (thewith_timeoutpattern) and creates one sharedArc<Semaphore>(UPSTREAM_FANOUT_LIMIT = 8). -
with_timeout/with_tokenare field-preserving builders — the deadline survives the manifestwith_timeoutclone, ridesSectionContext.clients, reaches every fetcher with no signature changes.test_service_clients+ otherServiceClientsliterals gain the new fields (mechanical).
D3. Enforcement — read verbs only, one absolute cutoff, permit held through decode
Reads only. Guard/gate/clamp apply to get, get_terminal_status, and
get_raw_streaming (header phase). Write verbs ignore the deadline entirely — a
timed-out write that committed upstream is exactly the ambiguity the client docs warn
about; no stamped handler writes today; bounded writes are #1320.
Un-stamped = untouched. With no deadline, the send paths run today’s exact
match self.call_timeout code (incl. `put_idempotent’s retry headroom). Opt-in
adoption per handler — not a compatibility layer.
With a deadline, per logical read call:
| Stage | Rule |
|---|---|
Entry guard |
|
Gate |
Acquire one owned permit for the whole logical call via
|
Post-acquire recheck |
Residual recomputed after the permit wait and before each attempt; below floor ⇒ typed fast-fail, zero server hits (closes the pass-then-queue race). |
Per-attempt clamp |
Attempt’s reqwest timeout = residual at that attempt’s start (covers connect→body for the attempt). |
Retry policy |
|
Body/decode |
|
-
No component-level
tokio::timeoutanywhere: page machinery never drops a future; completed partials render by construction. -
Leaf-only gating is deadlock-free (a permit is never held while awaiting another permit) and collapses today’s 8×8=64 nested-fan-out hole to a true 8 per request. `bounded_join’s private per-join semaphores stay (width shaping only).
-
Streaming:
get_raw_streamingtakes guard + gate through the header phase, then releases the permit and is exempt from the body cutoff (a download may outlive a page render; separate policy, doc-commented). No stamped surface streams today.
D4. Token acquisition — bounded, and honest downstream
with_service_identity bounds the acquire at min(TOKEN_ACQUIRE_CAP = 5s, residual).
On timeout/failure with a deadline present, it sets auth_unavailable = true on the
cloned clients and every read leaf fast-fails with typed kind = AuthUnavailable, zero
I/O ⇒ honest error panels + one token_timeout telemetry event — never an N×401 storm.
Un-stamped path keeps today’s proceed-unauthenticated behavior (MR1 inertness);
unification is #1324.
Ordering consequence (found in implementation, correct behavior): the D3 entry guard
runs before the auth check, so a token wait that consumes the whole page budget makes
subsequent reads classify DeadlineExceeded (time truly is exhausted), while a token
failure with budget remaining classifies AuthUnavailable. Both arms are pinned by
separate tests (hung-IdP ⇒ bounded return + deadline-class fast-fails; refused-IdP ⇒
AuthUnavailable with budget remaining and zero hits).
D5. ServiceError overhaul — typed, safe by default, centrally classified
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ServiceErrorKind {
DeadlineExceeded, Timeout, Transport, Http(u16),
Exhausted { last_status: Option<u16>, timeout_class: bool },
Decode, AuthUnavailable,
}
pub struct ServiceError { service: &'static str, kind: ServiceErrorKind, diagnostic: String }
-
Private fields; constructors (
::http,::timeout,::deadline,::transport,::exhausted,::decode,::auth_unavailable) +kind()/service()/diagnostic()accessors;is_time()= DeadlineExceeded | Timeout. -
Displayis safe:"{service} service error: {kind category}"— the upstream body moves todiagnostic, logged once server-side at the construction site. Fixes the two live sites renderingServiceError::to_string()to users (actions_snap_issuance.rs:37,actions_intake.rs:92); aligns with ADR-041. -
upstream_status()matchesHttp(s)(string-prefix parse deleted);ExhaustedreturnsNone— preserving the #594 exhaustion⇒502 contract (pinned by the existing 3-attempts-on-503 test) while telemetry seesexhausteddistinctly. -
Central classifier
classify_reqwest(&reqwest::Error) → ServiceErrorKindused by every error site (retried + single-shot verbs,status_error,json_or_status_error, raw/terminal-status/body reads) — a body-read timeout is never misfiled asDecode. -
Precedence: non-2xx headers ⇒
Http(status)even if the error-body read then times out; 2xx + body timeout ⇒ Timeout-class; page-vs-component tie ⇒DeadlineExceeded. -
One additive
retry.rschange (canopy-api):RetryError::Exhaustedrecords the last failure’s transport class (timeout/connect/other) instead of stringifying it. Additive field, own test.
D6. Honest states — inventoried across every stamped surface
Bounding calls without state work would increase false-empty renders, so every
stamped surface gets an explicit decision. Render vocabulary stays the four-state
manifest contract — timeout is state="error" with distinct static copy (is_time()
⇒ "Took too long to load — try again" / else "Couldn’t load right now") through the
existing panel_error/error_block machinery; the 5-way distinction (incl. partial)
lives in telemetry.
my_queue (MR2)
fetch_items → (Vec<WorkQueueItem>, SourceOutcomes); sources = applications,
renewals (per-leg: Partial { failed, of } when some program legs fail), appeals:
SourceOutcome { Ok, Partial{..}, Failed(kind) }. One shared queue_state() consumed
by both the dashboard panel and GET /cases:
| Sources | Items | Renders |
|---|---|---|
all failed |
— |
|
any failed/partial |
≥1 |
|
any failed/partial |
0 |
|
none failed |
0 |
|
none failed |
≥1 |
|
Name-resolution misses stay cosmetic. Third caller command_palette.rs:117
destructures-and-ignores (per-keystroke, no state UI; doc-commented).
Other MR2 surfaces
-
Panel audit: exactly two panels COLLAPSE (inventory below) — my_queue and
recent_determinations(collapse at recent_determinations.rs:57/:80, masked as "empty" at :107; gains the three-way split + per-program partial "—" rows). The 12 three-way panels + 4 visible-degrade panels are verified, not rewritten. -
Dashboard hero:
fetch_hero→Result; Err ⇒ "—" (never a fabricated 0). -
/cases/search rows:
PersonResultgains typed ancillary outcomes (status lookup
household-program lookup:Ok(value) | Failed(kind)) — a failed status lookup renders "Unavailable — retry", distinct from the genuine "Pending"; a failed program lookup renders distinct from "—". Primary person rows and links always render; degradation adds a banner above results, never replaces them. -
templates/cases/search.htmlgains state arms (today only{% if !queue_items.is_empty() %}).
Case-detail (MR3)
-
Hero: (a) runs concurrently with the section fan-out (
tokio::join!, the dashboard’s shape) so a slow section wave cannot starve it — enabled by dropping the unused_sectionsparam; (b) honesty inside: member count "—" on persons failure (today fabricates 0 at case_detail.rs:1664), determination/certification/ELE failures render degraded lines instead of silent suppression; SNAP-cert fetch joins the concurrent block. -
?program=all: per-row matrix honesty —ProgramDetOutcome { Determined(..), NoneFound, NotConfigured, Failed { is_time } }. Ok-empty ⇒ "No determinations" (a SNAP error must not silence TANF’s genuine empty);NotConfigureddriven by the existingservice_configuredsignal (case_detail.rs:2022-2027); Err ⇒ error/timeout row.tr[data-program=…]attrs kept. -
Sections:
RenderedSection/RenderedPanelgainoutcome: ComponentOutcomeset explicitly by each fetcher; every COLLAPSE row in the inventory below is fixed — each silently-collapsing leg either maps to an explicit outcome/degraded render or carries// SILENT-OK: <reason>. Audit oracle: the.ok()/unwrap_or_default()/if let Okgrep sweep + per-fixed-section mixed-feed tests.
D7. Manifest timeout_ms activation — both dispatch paths
-
sections::dispatch_fetch: resolve the manifest viactx.plugins.find_case_section(the injected source — not the panel dispatcher’s hardcodedCompileTimePluginSource, which would bypass test plugin sources) and scopectx.clients.with_timeout(manifest_ms). -
The 13 explicit tab arms in
get_tabcall renderers directly and never passdispatch_fetch— a shared helpersection_scoped_clients(plugins, slug)applies the same manifest cap before each arm’s renderer. -
DEFAULT_PANEL_TIMEOUT_MShoists to one sharedDEFAULT_COMPONENT_TIMEOUT_MS. -
Semantics unchanged from #527 (a per-call cap); it becomes
component_capin the D1 cutoff.
D8. Config — two knobs, bounded, overridable
#[serde(default = "default_page_deadline_ms")] // = 12_000
pub page_deadline_ms: u64,
#[serde(default = "default_fragment_deadline_ms")] // = 8_000
pub fragment_deadline_ms: u64,
-
Two budgets because the ceilings differ: full pages render under the 15s navigation budget (12s + ~3s margin); htmx fragments (
get_tab, panel retry,/cases/search) render under the 10s htmx response wait (tests/e2e/lib/helpers.ts:7) ⇒ 8s. -
Boot validation: page ∈ [1_000, 14_000], fragment ∈ [1_000, 9_000]; out-of-range rejects at boot — unless
CANOPY_WEB__DEADLINE_OVERRIDE=true(per-control accountable override; deployment owns the risk; loud warn every boot;from_nowstill hard-caps at 600s). Raising budgets is never the fix for a slow upstream. -
Production default in
config/canopy-web/default.yaml;WebConfigliteral incomposition_api_test.rs:144updated;deadline.rswired into the module tree. Tests may constructWebConfigliterals directly with sub-minimum values (validation runs at env load). -
configuration-reference.adoclands in MR1 with the knobs.
D9. Telemetry — services/canopy-web/src/telemetry.rs, exporter-tested
Prereq: opentelemetry workspace dep added to canopy-web (none today). Singleton
instruments via OnceLock; meter global::meter("canopy_web").
| Instrument | Recording point | Labels |
|---|---|---|
|
Once per logical call, in the verb tail after status+decode (recording in
|
service; kind ∈ {ok, deadline_exceeded, timeout, transport, http_4xx, http_5xx, exhausted, decode, auth_unavailable, token_timeout} |
|
Fetchers/finalize helpers via |
surface {dashboard, cases, case_search, case_detail, tab, program_all}; component = slug normalized against the compile-time plugin registry (unknown ⇒ "unknown"); outcome {populated, empty, partial, error, timeout} |
|
Every handler exit path (scopeguard at handler top — early errors included) |
surface; outcome {ok, degraded, error} |
Redaction boundary is structural: only kind names + registry slugs cross into metrics;
diagnostic never does. Tests use a manual exporter/reader asserting exact counts and
the full label vocabulary.
Erratum (2026-08-04, MR3): the registry-only normalization above would collapse the
non-plugin components (hero, search rows, program-all rows) to "unknown", defeating
their D9 purpose. As built, normalization additionally accepts the closed compile-time
STATIC_COMPONENTS allowlist (hero, search_rows, program_all) — still a closed
label set with zero cardinality growth; the redaction boundary is unchanged.
Inventory (verified 2026-08-04 against main @ bf6bbf79)
Citation base: services/canopy-web/src/; cd.rs = api/case_detail.rs,
dv.rs = determination_view.rs. Verdicts: OK = explicit error state / visible
degrade on every leg; COLLAPSE = at least one leg’s failure renders as empty/absent
data; NO-UPSTREAM = no network calls.
Case-detail sections (23): 15 COLLAPSE / 6 OK / 2 NO-UPSTREAM
| slug | fetcher path | upstream calls | current Err handling | verdict |
|---|---|---|---|---|
household |
sections/household.rs:22 → render_household_tab cd.rs:2520 |
persons households/{id}; per-member SERIAL get_person_info; persons addresses (cd.rs:2454); (SNAP) renewals certifications — 3+N serial |
household Err → warn + |
COLLAPSE — households failure false-empties the member table; renewals outage reads as "No active certification" |
income |
sections/income.rs:21 → render_income_tab cd.rs:2951 |
persons households/{id}/full; (SNAP) verification discrepancies (cd.rs:3480) — 2 serial |
/full Err → explicit |
COLLAPSE (partial) — the IEVS discrepancy leg’s failure disappears |
determination |
sections/determination.rs:20 → render_determination_tab cd.rs:3561 |
assemble_determination_view (dv.rs:463): persons households/{id} (dv.rs:521); SERIAL
per program: eligibility dets (dv.rs:549), tanf dets (dv.rs:570), medicaid dets
(dv.rs:614) + serial resolve_name (dv.rs:627), caps dets (dv.rs:671) + resolve_name |
Every leg silent: |
COLLAPSE — all five program legs + enrollment/tsnap/hearing/open-actions vanish on Err |
notices |
sections/notices.rs:21 → render_notices_tab cd.rs:3632 |
notices ?household_id&program&limit=50 — 1 call |
|
COLLAPSE — outage renders as "no notices" |
renewals |
sections/renewals.rs:25 → render_renewals_tab cd.rs:3702 |
renewals snap/nudges?household_id — 1 call |
|
COLLAPSE — outage renders as "no pending nudges" |
appeals |
sections/appeals.rs:20 → render_appeals_tab cd.rs:3742 |
appeals ?household_id&limit=50 — 1 call |
|
COLLAPSE — outage renders as "no appeals" |
activity |
sections/activity.rs:21 → render_activity_tab cd.rs:3799 |
security events?household_id (limit 50) — 1 call; #1309 BFF scope filter post-fetch |
explicit |
OK |
abawd |
sections/abawd.rs:23 → render_program_tab cd.rs:3861 |
snap abawd/tracking — 1 call |
Ok-empty → "No data" card (cd.rs:3922-3931); Err → visible service-error block (cd.rs:3950-3963); unconfigured → visible error (cd.rs:3871-3877) |
OK |
work_req |
sections/work_req.rs:21 → render_tanf_work_req cd.rs:3970 |
tanf work-requirements/{hh}; + /activities — 2 serial |
requirement |
COLLAPSE — both legs render as "no work requirement / no activities" |
time_limits |
sections/time_limits.rs:20 → render_tanf_time_limits cd.rs:4048 |
tanf time-limits/{hh} — 1 call |
|
COLLAPSE — outage renders as "no time-limit data" |
categories |
sections/categories.rs:20 → render_medicaid_categories cd.rs:4065 |
medicaid dets?limit=200; + applications/{app}/categories — 2 serial |
both |
COLLAPSE — both legs render as "no categories" |
authorization |
sections/authorization.rs:20 → render_caps_authorization cd.rs:4127 |
caps dets?household_id; + dets/{det}/authorizations — 2 serial |
dets |
COLLAPSE — both legs render as "no authorizations" |
nutrition |
sections/nutrition.rs:20 → render_wic_nutrition cd.rs:4207 |
persons households/{id}; per-member SERIAL wic risk assessments — 1+N serial |
household Err → warn + empty members (cd.rs:4223-4226); per-member Err → warn |
COLLAPSE — household and per-member failures render as "no assessments" |
guidance |
sections/guidance.rs:20 → render_guidance_tab cd.rs:4278 |
none (WorkflowTemplates from Extension) |
template render fallback only |
NO-UPSTREAM |
assets |
sections/assets.rs:23 → render_assets_tab cd.rs:3282 |
persons /full — 1 call |
explicit |
OK |
expenses |
sections/expenses.rs:23 → render_expenses_tab cd.rs:3334 |
persons /full — 1 call |
explicit |
OK |
address |
sections/address.rs:32 → render_address_tab address.rs:184 |
persons /full — 1 call; #1310 confidentiality threaded in |
explicit |
OK |
persons |
sections/persons.rs:24 → render_persons_tab cd.rs:3389 |
persons /full; + persons households/{id} (member-id map) — 2 serial |
/full: explicit |
COLLAPSE (partial) — the member-id leg’s failure silently removes the Remove affordance |
verifications |
sections/verifications.rs:82 |
verification ?application_id&status=pending&limit=50; per-verification SERIAL /responses — 1+N serial |
list: explicit |
COLLAPSE (partial) — a failed responses leg reads as "applicant hasn’t responded" |
audit |
sections/audit.rs:116 |
tokio::join! 2-wide (audit.rs:129-134): security events + chain status (get_terminal_status) |
events Err → explicit |
OK |
fact_history |
sections/fact_history.rs:46 → api/fact_history.rs:155 |
persons /full (fact_history.rs:217); per-member CONCURRENT bounded_join security fact-change-history (fact_history.rs:254-277) |
household leg → explicit |
COLLAPSE (partial) — per-member failures vanish (partial history reads as complete) |
cross_program |
sections/cross_program.rs:20 |
none (coming-soon stub, #562) |
render_coming_soon only |
NO-UPSTREAM |
documents |
sections/documents.rs:71 |
applications /{id}/documents — 1 call (no-application short-circuit at :79) |
|
COLLAPSE — outage renders as "no documents uploaded" |
Dashboard panels (22): 2 COLLAPSE / 16 OK / 4 NO-UPSTREAM
| slug | current Err handling | verdict |
|---|---|---|
analyst_audit_export |
Stub — always |
NO-UPSTREAM |
analyst_case_search |
Stub — search form only (analyst_case_search.rs:29-36) |
NO-UPSTREAM |
analyst_pipeline_funnel |
Per-stage Err → None (:174-180); all-None → |
OK |
at_a_glance |
4-wide join; per-feed Err → "—" tile (at_a_glance.rs:54-65, 73-84) |
OK |
audit_events |
Three-way split (audit_events.rs:66-75) |
OK |
cross_program_alerts |
Three-way split (cross_program_alerts.rs:70-79) |
OK |
ievs_alerts |
Three-way split (ievs_alerts.rs:60-69) |
OK |
ievs_rollup |
Stub — always |
NO-UPSTREAM |
my_queue |
All three legs drop on Err ( |
COLLAPSE |
overdue_cases |
Three-way split (overdue_cases.rs:58-67) |
OK |
overpayment_rollup |
Three-way split (overpayment_rollup.rs:83-101) |
OK |
pending_hearings |
Three-way split (pending_hearings.rs:70-86, :114-127) |
OK |
pending_verifications |
Three-way split (pending_verifications.rs:60-69) |
OK |
recent_applications |
Main leg three-way split (:57-66); per-row outcome |
OK |
recent_determinations |
Per-program legs collapse: |
COLLAPSE |
recent_notices |
Three-way split (recent_notices.rs:55-64) |
OK |
sanctions_rollup |
Three-way split (sanctions_rollup.rs:60-77) |
OK |
supervisor_caseload_trend |
Err → None (:163-169) → |
OK |
supervisor_kpis |
Spine Err → |
OK |
system_messages |
Stub — always |
NO-UPSTREAM |
team_queue |
Three-way split (team_queue.rs:67-82) |
OK |
upcoming_appointments |
Three-way split (upcoming_appointments.rs:57-66) |
OK |
Review resolutions (external review, rev 3)
-
Token cancel-safety: the
mint_lockreasoning covers the self-validation path — which is production (bootstrap.rs:180); tests constructwith_self_validationmode and cancel at each stage (lock-wait, token HTTP, JWKS refresh, revalidation backoff). Dropping the future releases the tokio mutex (no poisoning) and skipsenter_cooldown(observed-failure only). A cancelled mint the IdP already processed is harmless: token discarded, next acquire re-mints. -
No e2e weakening:
dashboard.spec.ts:96-102keeps its table-or-earned-empty assertion unchanged — adding an error arm would let a broken devstack pass a healthy-stack test. A genuine my-queue source failure during e2e now fails the spec honestly; controlled-failure e2e is #1325. -
Claim discipline: this plan bounds upstream I/O within the budget; session extraction, local composition SQL, and template render are outside the envelope (local-PG, Err→500 class — documented residual). The acceptance claim is the aggregate upstream-I/O cutoff + honest states, not "pages always respond in 12s".
Delivery
Three MRs, each independently green (branches chain off the previous until merged).
MR1/MR2 use Relates to #1306; MR3 Closes #1306 (AC narrowed on-issue 2026-08-04).
Follow-ups filed + related: #1319 (remaining handlers), #1320 (bounded writes), #1321 (queue-name batch lookup), #1322 (determination assembler parallelism), #1323 (timeout retuning), #1324 (middleware stamping + token-semantics unification), #1325 (devstack fault injection + degraded-page e2e), #1326 (cross-request capacity).
Test plan
-
Cutoff integrity: gate-wait + retry/backoff + headers-just-before-cutoff + slow body ⇒ total bounded by
call_deadline(mock records request timestamps; last hit ≤ cutoff; zero hits post-expiry); saturated gate then single-shot call (no double budget); ≤8 active non-streaming bodies; expiry immediately after permit acquisition ⇒ zero server hits. -
Classification: JSON/raw/terminal-status/non-success-body timeout mapping; repeated attempt-timeout vs repeated-503 exhaustion; 2xx+body-timeout ⇒ Timeout not Decode; binding-arm ties ⇒ DeadlineExceeded.
-
Threading: identical deadline + same
Arc<Semaphore>throughwith_timeout/with_tokenin both orders. -
Token: canopy-web side — hung-IdP ⇒ bounded return + deadline-class fast-fails; refused-IdP ⇒
AuthUnavailableon all read verbs with budget remaining + zero hits +token_timeoutmetric on lapse. canopy-auth side —with_self_validationcancellation regressions (cancel mid-mint; cancel during lock-wait; revalidation- backoff stage only if existing mock knobs reach it): subsequent acquire succeeds, no cooldown entered by a cancellation. -
Un-stamped parity: every verb’s deadline behavior identical with no deadline — same request path/timeouts/retry policy; the typed-error construction and call-outcome telemetry apply in both regimes (incl.
put_idempotentheadroom); 3-attempts-on-503 pin stays green. -
Retry policy: 0.8×-budget success (fails under the old ÷3 split, passes now).
-
Streaming: default + explicit timeouts; body outlives a page deadline.
-
Pure math: proptests on
remaining_at/clamp_at/floor/saturation with injectednow— no wall clock. -
States:
queue_statetotality proptest (neveremptywith failures; neverpopulatedwith 0 items + failures); per-surface all-fail / partial / early-ok-late-hang with never-false-empty oracles; per-fixed-panel and per-fixed-section mixed-feed tests;?program=allmatrix. -
Timing assertions are deadline-relative (elapsed ≤ deadline + fixed allowance).
-
Metrics: manual-reader exporter tests — exact counts + label vocabulary.
Harness (MR2 builds it): Tier a — fault matrix at the fetch pipelines (stamped
test_service_clients + spawn_router mocks; the #1310/#1309 idioms; no DB).
Tier b — full-handler proof per surface, split by what each handler transitively
requires (deviation from rev 3, recorded 2026-08-04: the original "explicitly
constructed CompositionState, no RabbitMQ" is unconstructible —
canopy_mq::ConnectionManager::new connects to the broker eagerly and Publisher
has no broker-less constructor, and load_composition propagates
fetch_db_layers errors with no filesystem fallback, so any CompositionState
demands live RabbitMQ + PG):
-
Cases family (
GET /cases,GET /cases/search— noCompositionState): true full-router proof — realrequire_auth+ Extension stack + MemoryStore sessions (write_authz_route_tests.rsprecedent) + directWebConfigliterals +spawn_routermock upstreams. Zero infrastructure. -
Dashboard family (
get_dashboard,get_panel_fragment— needCompositionStateonly to load the composition): proof at the injected-composition seam —render_dashboard_inner/panel_fragment_innertake an already-ComposedSurface(all-pub struct, hand-constructible), so the panels-fanout + hero + render path is proven with zero infrastructure. Composition loading stays covered bytests/composition_api_test.rs(devstack-gated) and the e2e battery.
Verification
Per MR: cargo fmt --check --all · cargo clippy --all-targets — -D warnings ·
RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc --workspace --no-deps ·
cargo xtask quality-budgets · cargo xtask check-docs · full pre-push battery
(cargo xtask validate; JUnit XML → test-results/) · api-docs no-op check (BFF-only).
MR1’s gate: no render-path change + the new unit/proptest suite. MR2/MR3 gates: the
fault matrices above.