Summary
Blocking a user does not stop them sending. user_connections.status = 'blocked' is enforced nowhere on the send path — on either backend. Once a 1:1 conversation exists, sending is gated on participant, not on connection status, so a blocked user keeps messaging the person who blocked them.
Found while shipping #265's C3 increment (PR #351). Pre-existing and orthogonal to that work, so it was deliberately left out of scope rather than widening the increment.
Verified
Confirmed at all three layers on main (8a705ac):
1. RLS — supabase/migrations/20251006_complete_monolithic_setup.sql, messages INSERT (1:1 :1945, group :2715):
FOR INSERT WITH CHECK (
sender_id = auth.uid() AND
EXISTS (
SELECT 1 FROM conversations
WHERE conversations.id = conversation_id AND (
conversations.participant_1_id = auth.uid() OR
conversations.participant_2_id = auth.uid()
)
)
);
No reference to user_connections. Grepping the whole migration, user_connections is consulted only by admin-stats functions and its own policies — never by a messages policy. 'blocked' appears only in the status CHECK constraint and an admin dashboard count.
2. TypeScript — src/services/messaging/message-service.ts has no connection-status check on sendMessage (only doc-comment prose mentioning "connections").
3. .NET — ConversationsController.Send gates on MessagingQueries.CanAccessConversation (participant / active member). HasAcceptedConnection exists but is called only by Create (the C3 path added in #351).
So both backends agree — which means this is a genuine contract gap, not provider drift.
Repro
- A and B connect (
accepted), open a conversation, exchange messages.
- B blocks A →
user_connections.status = 'blocked'.
- A sends into the existing conversation → succeeds.
Note step 1 matters: with no pre-existing conversation, C3 (getOrCreateConversation) correctly refuses to create one, since it requires status = 'accepted'. The hole is only for conversations that already exist — i.e. exactly the people most likely to be blocked.
Why it wasn't caught
C8's contract text is "On send, sender must be the caller AND an active participant/member." That is implemented faithfully. The rule as written simply never contemplated block; the conformance suite asserts the rule, and the rule is the gap.
Design questions to settle before implementing
Blocking has more surface than the send path, and the answers should be decided together rather than patched piecemeal:
- Send — reject outright, or accept-and-drop (so the blocker isn't signalled)?
- Read — can a blocked user still read history? RLS
conversations SELECT (:1843) and messages SELECT (:1933) are participant-scoped and connection-independent, so today: yes.
- Directionality —
unique_connection is (requester_id, addressee_id) and is not symmetric. Does a block by either party stop both directions? (Almost certainly yes, but the check must test both orderings, the way C3's HasAcceptedConnection does.)
- Realtime — a blocked sender's writes currently still fire
postgres_changes to the blocker.
- Groups — does a 1:1 block affect a shared group conversation?
Definition of done
Per docs/messaging/AUTHORIZATION-CONTRACT.md's maintenance rule, this becomes a real clause only with canonical rule text + a conformance case:
Context
Part of the #280 enterprise arc / #265 seam-expansion. Sits naturally alongside the remaining backlog items (group management, key rotation, GDPR), since block semantics touch group membership too.
Summary
Blocking a user does not stop them sending.
user_connections.status = 'blocked'is enforced nowhere on the send path — on either backend. Once a 1:1 conversation exists, sending is gated on participant, not on connection status, so a blocked user keeps messaging the person who blocked them.Found while shipping #265's C3 increment (PR #351). Pre-existing and orthogonal to that work, so it was deliberately left out of scope rather than widening the increment.
Verified
Confirmed at all three layers on
main(8a705ac):1. RLS —
supabase/migrations/20251006_complete_monolithic_setup.sql,messagesINSERT (1:1:1945, group:2715):No reference to
user_connections. Grepping the whole migration,user_connectionsis consulted only by admin-stats functions and its own policies — never by amessagespolicy.'blocked'appears only in thestatusCHECK constraint and an admin dashboard count.2. TypeScript —
src/services/messaging/message-service.tshas no connection-status check onsendMessage(only doc-comment prose mentioning "connections").3. .NET —
ConversationsController.Sendgates onMessagingQueries.CanAccessConversation(participant / active member).HasAcceptedConnectionexists but is called only byCreate(the C3 path added in #351).So both backends agree — which means this is a genuine contract gap, not provider drift.
Repro
accepted), open a conversation, exchange messages.user_connections.status = 'blocked'.Note step 1 matters: with no pre-existing conversation, C3 (
getOrCreateConversation) correctly refuses to create one, since it requiresstatus = 'accepted'. The hole is only for conversations that already exist — i.e. exactly the people most likely to be blocked.Why it wasn't caught
C8's contract text is "On send, sender must be the caller AND an active participant/member." That is implemented faithfully. The rule as written simply never contemplated block; the conformance suite asserts the rule, and the rule is the gap.
Design questions to settle before implementing
Blocking has more surface than the send path, and the answers should be decided together rather than patched piecemeal:
conversationsSELECT (:1843) andmessagesSELECT (:1933) are participant-scoped and connection-independent, so today: yes.unique_connectionis(requester_id, addressee_id)and is not symmetric. Does a block by either party stop both directions? (Almost certainly yes, but the check must test both orderings, the way C3'sHasAcceptedConnectiondoes.)postgres_changesto the blocker.Definition of done
Per
docs/messaging/AUTHORIZATION-CONTRACT.md's maintenance rule, this becomes a real clause only with canonical rule text + a conformance case:assertRefusalhook must pin a 403, not a 5xx — otherwise RLS masks a missing C# check and the case passes with the rule deleted. See the mutation-test writeup inAUTHORIZATION-CONTRACT.md→ "C3 and the two things a conformance case must prove".Context
Part of the #280 enterprise arc / #265 seam-expansion. Sits naturally alongside the remaining backlog items (group management, key rotation, GDPR), since block semantics touch group membership too.