Story-2854

Live membership + binding revocation + revocation epoch

This story lands the per-message authorization seam for channel ingress (RFD-AGT-013 D19, TICKET-MAP v2 T2e-a), on top of Story-2848's identity service (src/domains/channel/service.py), Story-2849's verified ingress, and Story-2851's delivery path. Zero new migrations — the revocation-epoch table is Story-2847's schema seed (0207). Everything here is behavior: the live mirror check, the offboard sweep, the artifact epoch gate, and the CC-43 re-join continuity proof.

What problem this solves

Enrollment state is a point-in-time proof; membership is a moving truth. A counsel removed from a firm must be refused at MESSAGE time — re-verified live against the Postgres firm-membership mirror on every inbound message, never assumed from a binding row that still says 'active'. The founder accepted the residual window (webhook lag, seconds — Q2): no synchronous Clerk call, no special engineering. But when the mirror itself cannot be read, the check fails CLOSED (CC-48): a lookup error is a refusal, never an allow.

The three surfaces (src/domains/channel/membership.py)

SurfaceCallerContract
resolve_live_bindings Channel-ingress consumer (privileged session), per message Story-2848's active-binding resolution, then two live filters: the member must be 'active' in identity.firm_memberships RIGHT NOW, and the binding's stamp must not predate the (firm, user) revocation epoch. Fail-closed: a mirror/epoch lookup error raises ChannelMembershipCheckError (CC-48). Empty list = no authorized candidate, no run.
assert_binding_deliverable dispatch_whatsapp_text (Story-2851), per outbound send The mid-thread revocation guard: a run that started while the member was live cannot deliver its answer after unbind/admin-revoke/offboard landed. Binding must be 'active' AND epoch-current, else ChannelBindingRevokedError and no bytes cross. T2e-b's zero-firm terminal notice (CC-22) is the one message deliberately exempt (enforce_live_binding=False).
sweep_membership_removal Clerk webhook dispatcher (organizationMembership.deleted) One transaction with the mirror status flip (D19): bump the (firm, user) revocation epoch (monotonic — never moves backward), revoke the member's active bindings (reason 'offboard'), drop sticky context, kill outstanding fetch tokens (CC-24), abort pending confirmations. Scoped to the removed (firm, user) — other firms keep working (CC-23). One channel_identity.revoked audit row per binding (R5).

Replay safety

The dispatcher sweeps only on a REAL transition to 'removed'. A redelivered organizationMembership.deleted (membership already removed) skips the sweep entirely — re-bumping the epoch could otherwise kill a binding the member minted after re-joining. The epoch upsert itself is monotonic (GREATEST(old, new)) as a second layer. Both halves are pinned by test_offboard_webhook_replay_does_not_rebump_the_epoch.

CC-43 — re-joined counsel resumes their conversation

Memory is keyed by (agent, matter, counsel) — never by binding (Story-2839's shared resolver, src/shared/chat_session_resolver.py). The sweep deliberately does NOT touch agents.chat_session: history is preserved. The continuity test walks the whole journey: enroll → converse (7 turns) → offboard webhook (binding dies, channel refuses) → re-added to the firm → re-enroll (a FRESH binding) → the first message routes to the SAME session, turn count intact. The re-joined counsel is never re-onboarded as a stranger.

How to verify (CC-23 / CC-43 / CC-48)

# DB proofs (Docker test DB): live-check authorization + refusal,
# CC-48 fail-closed, offboard sweep mid-thread, replay safety, CC-23
# two-firm scoping, epoch-kills-stale-artifact, CC-43 resume
make test-db
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_channel_live_membership.py

# Neighbors that must stay green: 2848 identity service, 2851 delivery,
# the Clerk dispatcher + webhook route, the error contract gate
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_channel_identity_service.py \
  tests/integration/test_channel_delivery_db.py \
  tests/services/test_clerk_dispatcher.py \
  tests/integration/test_webhooks_clerk_route.py \
  tests/unit/shared/errors/test_error_contract_gate.py

Red-first evidence

Three planted negatives were observed red against the shipped implementation: (1) removing the live-membership filter from resolve_live_bindings fails test_membership_removal_refuses_the_next_message; (2) neutering the epoch comparison fails test_epoch_kills_a_binding_stamped_before_revoked_before; (3) making the dispatcher sweep on replays fails test_offboard_webhook_replay_does_not_rebump_the_epoch.

Scope boundaries (TICKET-MAP v2)