Story 2879: admin channel register API — live bindings + metadata-only activity

RFD-AGT-013 CC-50 / TICKET-MAP T2k: the firm admin opens the channel activity register and sees each live binding plus a metadata-only rollup of fetches and commands. Admin visibility is the compensating control that justifies skipping step-up auth, so this endpoint is a security surface with a hard payload discipline: counts and timestamps only — never message content, never provider internals, subjects always masked. This gates the W3 launch (with T2f-b).

What changed

SurfaceChange
GET /app/channels/register (new, src/domains/channel/router.py) The firm-admin register. Returns ChannelRegisterResponse: every binding in the firm (masked subject, status, bound/revoked stamps) with a per-binding activity rollup (totalDeliveries, failedDeliveries, fetchLinksSent, lastDeliveryAt, lastDeliveredAt, lastReadAt, commandsReceived, lastCommandAt) and a firm-level refusal summary (total, byKind, lastAttemptedAt). Read-only.
require_firm_admin (src/shared/auth/permissions.py) New auth gate: the caller's active membership role must be firm_admin. Permissions are too coarse for this surface — settings:write is also held by firm_editor and support, and billing:manage is money semantics. Fail-closed: an org-less token (role None) or any other role is a 403 with an authz_denied log line.
src/domains/channel/register.py (new) The read service. Four constant queries regardless of row counts (ADR-130 DR-1): the binding list plus one grouped rollup each over channel_delivery_records, channel_inbound_messages, and channel_refusal_ledger, zipped in memory. No N+1. Every statement runs on the firm-scoped RLS session, so cross-firm rows are invisible at the database layer; zero-firm refusal rows (firm_id NULL) are staff-visible only and never appear.
GET /app/channels/firm-bindings (deleted) The Story-2848 placeholder seam ("T2k owns the full admin register with activity"). This PR is T2k: per R1 the replaced mechanism is deleted in the same PR — route, service.list_firm_bindings, and its tests. No consumer exists in pramaan-app. The POST /firm-bindings/{id}/revoke admin kill is untouched.
openapi/app-api.json, openapi/permission-map.json Snapshots regenerated (make openapi, make gen-permissions).

Metadata-only contract

The register payload carries bindings (subjects masked, •••1234), activity counts and timestamps, and refusal counts. It never carries: message content (none exists in the channel tables), provider message ids, provider thread ids, template names, payload hashes, traceparents, provider error text, or unmasked subjects. The contract test seeds rows that contain all of those in the database and asserts none appear anywhere in the response — by key (case-insensitive walk) and by value.

Tests

ProofWhere
Admin sees own firm's register: two live bindings with exact activity rollups and the refusal summary tests/integration/test_app_channel_register.py::test_admin_sees_own_firm_register_with_metadata_activity
firm_member AND firm_editor (with settings:write) are 403; firm_admin with no claim permissions is 200 — the gate is the role …::test_register_is_firm_admin_only
Cross-firm binding/delivery/refusal rows are invisible in every rollup …::test_register_hides_other_firms
Metadata-only payload contract (forbidden keys + seeded secret values absent) …::test_register_payload_is_metadata_only
O(1) query count: 1 binding vs 5 bindings (+deliveries, +refusals) → identical db_query_count (DR-1/HR-19) …::test_register_query_count_is_o1
require_firm_admin unit proofs: admin allowed; editor/member/support/None refused; authz_denied log shape tests/unit/test_authz_permissions.py

Review gate

  1. New cloud capability? None — reads existing seed tables via the firm-scoped RLS session; no migrations, no IAM, no network path.
  2. Who grants it? N/A — no new capability.
  3. Named check that goes red? tests/integration/test_app_channel_register.py (route, authz, cross-firm, payload contract, O(1)) and tests/unit/test_authz_permissions.py (require_firm_admin).
  4. Observed red? Yes — the first local run of test_admin_sees_own_firm_register_with_metadata_activity failed before the register route/service existed (404); the require_firm_admin unit tests fail against the base (import error). Green runs are in this branch's history.
  5. Legacy deleted in THIS PR? Yes — GET /app/channels/firm-bindings and service.list_firm_bindings (the Story-2848 placeholder this ticket replaces) plus their tests. Nothing stays temporary.
  6. Deploy smoke exercises the change? The change is a read-only app-API route behind the existing Lambda deploy; no worker deploy smoke applies. Route proofs hit the real ASGI app against real Postgres with RLS active.

Known pre-existing state

20 unit tests fail identically on the base commit (test_user_updated_webhook, test_staff_invite_user_created, test_firm_support_staff, test_clerk_dispatcher_user_profile_fields — Clerk profile-field cluster, unrelated to channels). Verified by stashing this branch's changes and re-running on the clean base.