refactor(web): unify program-scope read-denial shapes (403/422 + shared fragment)
What
canopy-web's program-scope READ denials ship in three shapes (#1518 (closed) kept them rather than widening three more handler signatures):
get_case_detailparticipation denial → bareErr(StatusCode::FORBIDDEN)(empty 403 body; same shape as the pre-existing?program=query gate at case_detail.rs).get_tab→ HTTP 200 with the denial banner (render_tab_scope_denied/denied.fragment()) — an intended authz banner is recorded as an OK page.get_intake_application→ HTTP 200 withrender_program_scope_denied.- (The #1518 (closed)-added surfaces — process page, artifact streams, fact-history — already return real 403/422 with the ScopeDenied fragment.)
The 200-banner shapes are not disclosures (static copy, no data), but they are dishonest statuses: monitoring can't distinguish a denial from a render, and the &78 (closed) plan's C2 called the four-shapes state out as a defect.
Acceptance criteria
- One denial contract for scope/participation-denied reads: real status (403 out-of-scope / 422 no-or-malformed-authority) + the shared
ScopeDeniedfragment (full-page-wrapped where the surface is a page, bare fragment on htmx paths). -
get_case_detail's bare-403 gains the fragment body;get_tab+get_intake_applicationstop returning 200 for denials (the htmx swap targets tolerate non-200 — verifyhx-swapbehavior or addHX-Retarget). - The scope-denial telemetry (
record_scope_denied) fires identically across all surfaces. - Routed tests pin status + fragment per surface.
Context
Epic &78 (closed) / #1518 (closed) (c-deviations item 6 in plans/program-scope-enforcement.adoc). Relates to #1516 (closed)'s mutation-side denial work (403 + banner, already unified).