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)
| Surface | Caller | Contract |
|---|---|---|
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)
- No migrations: the epoch table is Story-2847's (0207). The sweep
re-scopes the webhook session to the removed member's firm (the
consume_enrollment_codeprecedent) — no new RLS policy was needed. - The zero-firm terminal notice + refusal ledger (CC-22/CC-41) is T2e-b; this story ships the refusal SIGNAL (empty live resolution / typed errors), not the notice.
- The SQS ingress consumer that will call
resolve_live_bindingsper message lands with the consumer ticket; the seam is delivered and DB-proven here. - Fetch-token mint/open (T3b-1) and the global kill-path integration (T3b-3/T3-sweep) consume the epoch; the sweep already kills outstanding tokens directly.
- Remote logout (T2c-b) will bump the same epoch for ALL the user's firms — the mechanism ships here, per (firm, user).