Story-3061 · re-scoped 2026-07-29

Route-inventory guard for ChannelBindingView scope

Part of epic #3059 — Settings → Channels (WhatsApp) redesign. Story-3037 made the data visible; this change makes the invariant that keeps it safe executable. That single sentence is the whole value of what remains.

This ticket was re-scoped. It originally asked to put the full provider_subject on ChannelBindingView and retain provider_subject_masked. Story-3037 (pramaan-functions#855) delivered the exposure first — it added provider_subject, deleted provider_subject_masked outright ("no compatibility alias remains"), unmasked both constructors, flipped the old leak canary, rewrote the register docstring, and regenerated the OpenAPI. The "retain masked" criterion was therefore withdrawn: re-adding a field main deliberately removed would resurrect deleted legacy (AGENTS.md R1). This PR changes no DTO and no OpenAPI.

The invariant, and why it needs a guard

The full E.164 subject is PII. What makes exposing it safe is a single property:

A caller may see the full subject of a binding they own, or of any binding in their firm if they are a firm admin.

Story-3037 unmasked the data but left nothing that enforces the routes stay inside that boundary. A fifth route added later would inherit full phone numbers silently. This Story is that missing guard.

The four routes that return the view

RouteScopeHow it holds the invariant
GET /app/channels/bindings own list_my_bindings filters to _current_user_id()
DELETE /app/channels/bindings/{id} own unbind raises the uniform 404 unless user_id matches
GET /app/channels/register firm admin require_firm_admin
POST /app/channels/firm-bindings/{id}/revoke firm admin require_firm_admin — flipped from settings:write in Bug-3060 (#857)

The guard

tests/unit/test_channel_binding_view_route_inventory.py walks the live FastAPI route table, finds every route whose response embeds a ChannelBindingView (directly, in a list, or nested), and asserts each is classified own-scoped or firm-admin-scoped. A firm-admin route must actually carry require_firm_admin in its dependency tree — settings:write is not enough, because firm_editor and support hold it too (per require_firm_admin's own docstring). A future route returning the view without a gate fails the classification test the moment it is mounted.

discovered = every route whose response embeds ChannelBindingView
              ── GET  /app/channels/bindings                     (own)
              ── DELETE /app/channels/bindings/{id}              (own)
              ── GET  /app/channels/register                     (require_firm_admin)
              ── POST /app/channels/firm-bindings/{id}/revoke    (require_firm_admin)

assert discovered ⊆ OWN_SCOPED ∪ FIRM_ADMIN     # no unclassified route
assert every FIRM_ADMIN route has require_firm_admin in its dependency tree

Why this stacks on Bug-3060

Until Bug-3060 (#857) lands, the revoke route is settings:write, which firm_editor and support hold — so it returns the full subject outside the admin boundary and the guard's test_firm_admin_routes_enforce_require_firm_admin is red on that route (verified: the tripwire fires on the real broken state — AGENTS.md #3). This branch is therefore based on issue-3060-revoke-gate-firm-admin, where route 4 is require_firm_admin and the guard is green. The PR must merge after #857; retarget the base to main once #857 lands.

Also in this change