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)
| Surface | Session | Contract |
|---|---|---|
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)
POST /app/channels/enrollment-codes— 201, plaintext code shown once. 403pramaan.channel_consent.requireduntil the firm accepts the current consent version.GET /app/channels/bindings— the caller's own bindings, masked subjects (•••1234) only.DELETE /app/channels/bindings/{id}— self-service unbind (CC-25).GET /app/channels/firm-bindings+POST /app/channels/firm-bindings/{id}/revoke— the admin register seam and firm-admin revoke, gatedsettings:write(T2k owns the full activity register, CC-50).
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
- New subject kinds
channel_identityandchannel_enrollment_code(registry edit only — never a migration). Actions:channel_identity.created,channel_identity.revoked,channel_enrollment_code.created. - The three mutating routes are in the audit coverage manifest
(
EMIT_ROUTES) and each emission is proved by a route-level test asserting the audit row. - Phone numbers are PII: only the masked form appears in labels, snapshots, and on the wire; the plaintext code never appears anywhere after issuance.
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)
- No migrations: all tables are Story-2847's (0207). The post-bind ping is recorded as a queued delivery record; actually sending it is T2d-a's sender.
- The per-(firm, user) revocation epoch is NOT bumped on unbind/admin-revoke — epoch sweeps belong to remote-logout/offboard (T2e-a/T2c-b). Single-binding removal revokes precisely.
- No channel ingress: webhook wax-seal, dedupe, and the worker that
will call
consume_enrollment_codeare T2a-3. - Consent acceptance UI/route is Story-2846's surface; this service only calls its gate.