Story-2848

Channel identity service + enrollment + unbind + revoke

This story lands the identity seam for all channel traffic (RFD-AGT-013 D4/D7/D21, TICKET-MAP v2 T2a-2), on top of the Story-2847 schema seed (migration 0207) and the Story-2846 consent gate (src/domains/channel_consent/). Zero new migrations — the seed owns the schema. Everything here is behavior: per-firm binding resolution, the hashed single-use enrollment ceremony, self-service unbind, firm-admin revoke, and the recorded post-bind ping.

What problem this solves

Before a single WhatsApp message can be trusted, the platform must know WHICH member of WHICH firm a phone number is — per firm, never one global phone→user row (D4), so one-firm removal is safe (CC-23). The binding can only be minted by the account holder's own authenticated app session (CC-18), only while the firm holds the current channel-transit consent version (D21), and it must die cleanly: instantly, precisely, and auditably (CC-25).

The four service surfaces (src/domains/channel/service.py)

SurfaceSessionContract
resolve_bindings Privileged (ingress worker, T2a-3) Subject → one ACTIVE binding per firm. Empty list = unregistered sender (the T2a-4 shield's refusal signal).
issue_enrollment_code Firm-scoped (app) Consent-gated (D21). Mints a 10-minute single-use code (K7QX-9PW4, unambiguous alphabet); only its SHA-256 hash is stored. Re-issue revokes the prior pending code.
consume_enrollment_code Privileged, re-scoped to the resolved firm FOR UPDATE lock → status/expiry/attempt checks → consent re-verified (a version bump re-closes the gate) → membership re-verified (D19) → conflict check → bind + consume + post-bind ping + audit. Every failure is ONE uniform ChannelEnrollmentRefusedError (CC-18); pending codes lock after 5 counted misses.
unbind / admin_revoke Firm-scoped (app) Precise to one binding: revoked stamp (reason + revoked_by), sticky context deleted, the binding's outstanding fetch tokens killed (CC-25). Uniform 404 across unknown / revoked / another member's / another firm's binding. The member's other bindings are untouched (CC-23).

HTTP routes (src/app_api, /app/channels)

The consume half of the ceremony deliberately has no HTTP route: only the verified ingress worker calls it, after the transport wax-seal has proven the sender. Binding without the account holder's app session is therefore impossible (CC-18).

Audit + registry

How to verify (CC-02 / CC-18 / CC-25)

# Unit pins: code format, normalization, masking, error contract
uv run pytest tests/unit/test_channel_enrollment_codes.py \
  tests/unit/shared/errors/test_error_contract_gate.py

# DB proofs (Docker test DB): ceremony, replay/expiry/lock, consent at both
# ends, conflict, unbind sweep, admin-revoke, CC-23 two-firm safety, RLS pins
make test-db
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_channel_identity_service.py

# Route proofs: consent-gated 403, masked lists, settings:write gate,
# unbind/revoke + audit-row emission
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_app_channel_routes.py

Scope boundaries (TICKET-MAP v2)