Task — engineering-spec@1

"User blocking - stop seeing a person's content, and stop receiving their messages"

doneTASK-CHAT-268
module chat · class product · priority p0 · created 2026-07-11 · shipped 2026-07-11
depends on none · blocks none

§1 - Description (BCP-14 normative)

  1. The chat service MUST expose POST /v1/chat/blocks (block a person), DELETE /v1/chat/blocks/:subject_id (unblock), and GET /v1/chat/blocks (the caller's own block list). All three are scoped to the calling subject: a block belongs to the person who created it and nobody else can read, create, or remove it.
  1. A block MUST be one-directional and private. It records "A no longer wishes to receive B's content". It MUST NOT notify B, appear in any list B can read, or change anything B observes about their own account. B continues to see the channel, continues to see their own messages in it, and is told nothing.
  1. A subject MUST NOT be able to block themselves. The service returns 400.
  1. Blocking MUST be enforced on the server, at every one of these four fan-out points, and MUST NOT rely on the client filtering anything:
  1. In a shared group channel, a message from a blocked person MUST be returned to the blocker with body, attachments, and reactions withheld, and a blocked_sender: true flag set. The message's position in the channel and its id MUST be preserved. The client renders a collapsed placeholder with an explicit "show anyway" affordance.
  1. In a direct message channel between the blocker and the blocked person, messages from the blocked person MUST NOT be returned to the blocker at all - not collapsed, not flagged, not present. The DM MUST disappear from the blocker's DM list while the block stands.
  1. The blocked person MUST still be able to post. Their message is persisted normally and they see it in their own client. It is simply never delivered to the blocker: no row in the blocker's message list, no WebSocket frame, no notification, no push. The service MUST NOT return an error to the blocked sender.
  1. Consequently, the service MUST NOT disclose the existence of a block to the blocked person through any channel: not a status code, not an error string, not a missing-read-receipt, not a delivery indicator. See §2 for why this is the single most important security property of this task.
  1. Reactions and mentions authored by a blocked person MUST be suppressed for the blocker: their reaction is not counted in the folded reaction set the blocker receives, and their @mention of the blocker raises no notification.
  1. Blocking MUST NOT remove either party from any channel, and MUST NOT be visible to other members of that channel. Channel membership and moderation are the administrator's business (TASK-CHAT-269); blocking is the individual's.
  1. Unblocking MUST restore everything immediately and completely. Messages the blocked person sent during the block become visible in place, in their original position, on the blocker's next fetch. Nothing is lost and nothing is back-dated.
  1. Blocks MUST NOT apply to the moderation queue. When an administrator reviews a report in TASK-CHAT-269, they see the reported content in full regardless of any block either party holds. An administrator who has blocked someone must still be able to adjudicate a report about them.
  1. Blocks MUST be tenant-scoped under row-level security with both USING and WITH CHECK, like every other chat table.
  1. Every block and unblock MUST emit exactly one audit row - chat.subject_blocked or chat.subject_unblocked - carrying the blocker as actor and the blocked subject id. The row MUST NOT be readable by the blocked person through any surface exposed to them.
  1. The web client MUST offer Block / Unblock in the member list and in the profile popover, and MUST confirm before applying it. Every string MUST ship in en and vi.

§2 - Why this design (rationale for humans)

Why the blocked person is never told (§1 #7, #8). This is the clause the task turns on, and it is counter-intuitive, so it is worth being blunt. The obvious design is to refuse the blocked person's message with a 403 - it is honest, it is simple, and it is dangerous. Telling a harasser "you have been blocked" is an escalation trigger: it converts a person who was being ignored into a person who knows they were rejected, and the documented pattern is that they escalate through another channel. Every mature messaging product - Signal, WhatsApp, iMessage - lets the blocked sender believe the message went out. So do we. The message is persisted, the sender sees it in their own client, and it simply never arrives. The blocker is protected, and the blocked person has nothing to react to.

Why group-channel messages are collapsed rather than deleted (§1 #5). Removing a blocked person's messages outright silently rewrites the channel's history for one participant: replies to a vanished message become nonsense, thread counts stop matching, and the blocker ends up more confused than protected. A collapsed placeholder preserves the shape of the conversation, tells the truth ("someone you blocked said something here"), and leaves the choice to reveal in the hands of the person who made the block. It is their block; they are allowed to un-hide their own view.

Why DM messages are removed entirely rather than collapsed (§1 #6). In a group channel, a collapsed row is context - it explains a gap in a conversation you are still part of. In a DM there is no conversation left to contextualise: a column of "blocked message" placeholders is not information, it is a drip-feed of the harassment you asked to stop. The whole DM leaves the list.

Why enforcement is at four points and not one (§1 #4). Because there are four ways content reaches a person, and filtering three of them is filtering none. It is entirely possible to hide the messages from the list and still push a phone notification carrying the blocked person's name and the first 80 characters of their message onto the blocker's lock screen. That is not a hypothetical - it is the default behaviour of the existing notify.rs fan-out, which selects channel members and does not know blocks exist. All four are named in §1 #4 and each has its own acceptance criterion, so a future contributor who adds a fifth fan-out path has a checklist telling them what they just broke.

Why blocks do not apply to the moderation queue (§1 #12). The likeliest reporter of a person is the same person who blocked them. If the block also hid the content from the moderation queue, the administrator - who may well be that same person in a small workspace - would be adjudicating a report they cannot read. The queue is a distinct surface with a distinct purpose and it renders raw.

Why blocking does not remove anyone from a channel (§1 #10). Because that is a moderation action with consequences for everyone else in the channel, and one member should not be able to trigger it unilaterally. Blocking changes what you see. Removal changes what everyone sees, and belongs to the administrator.

§3 - API contract

Migration

-- services/chat/migrations/0014_chat_blocks.sql
-- TASK-CHAT-268: one row per (blocker, blocked) pair. Directional: A blocking B says nothing about
-- whether B blocks A. Private: only the blocker can ever read their own rows.

CREATE TABLE IF NOT EXISTS chat_blocks (
    tenant_id           UUID NOT NULL,
    blocker_subject_id  UUID NOT NULL,
    blocked_subject_id  UUID NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),

    PRIMARY KEY (blocker_subject_id, blocked_subject_id),
    CONSTRAINT chat_blocks_not_self CHECK (blocker_subject_id <> blocked_subject_id)
);

-- The hot path is "give me every subject THIS caller has blocked", run once per message-list,
-- realtime fan-out, and notification fan-out. The PK already serves it (blocker is the leading
-- column); this index serves the reverse question the notification fan-out asks: "of the members
-- about to be notified, which of them have blocked the sender?"
CREATE INDEX IF NOT EXISTS chat_blocks_blocked_idx
    ON chat_blocks (blocked_subject_id, blocker_subject_id);

ALTER TABLE chat_blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE chat_blocks FORCE  ROW LEVEL SECURITY;
CREATE POLICY chat_blocks_tenant_scoped ON chat_blocks
    FOR ALL
    USING (
        tenant_id::text = current_setting('app.current_tenant_id', true)
        OR current_setting('app.current_tenant_id', true) = '00000000-0000-0000-0000-000000000000'
    )
    WITH CHECK (
        tenant_id::text = current_setting('app.current_tenant_id', true)
        OR current_setting('app.current_tenant_id', true) = '00000000-0000-0000-0000-000000000000'
    );

GRANT SELECT, INSERT, DELETE ON chat_blocks TO cyberos_app;
GRANT SELECT ON chat_blocks TO cyberos_ro;

Endpoints

POST   /v1/chat/blocks               { "subject_id": "<uuid>" }   -> 204 (idempotent)
DELETE /v1/chat/blocks/:subject_id                                -> 204 (idempotent)
GET    /v1/chat/blocks                                            -> 200 [ { subject_id, created_at } ]

400  "cannot block yourself"
401  token invalid

Both mutations are idempotent by design: blocking someone twice is a no-op, and unblocking someone you never blocked is a no-op. Neither returns 404 or 409, because a distinguishable response would let a caller enumerate their own block state through side effects - and, worse, invites a client to render an error for a state the user does not care about.

The blocked-set, and where it is applied

// services/chat/src/blocks.rs

/// Every subject the caller has blocked. Read ONCE per request and threaded through the four
/// enforcement points (§1 #4). A HashSet, not a per-message query: the message list is the hot
/// path and N+1 here would be the most expensive thing in the service.
pub async fn blocked_by(
    tx: &mut sqlx::PgConnection,
    blocker: Uuid,
) -> Result<HashSet<Uuid>, (StatusCode, String)> {
    let rows: Vec<(Uuid,)> = sqlx::query_as(
        "SELECT blocked_subject_id FROM chat_blocks WHERE blocker_subject_id = $1")
        .bind(blocker).fetch_all(&mut *tx).await.map_err(db::internal)?;
    Ok(rows.into_iter().map(|(id,)| id).collect())
}

/// The reverse question, asked by the notification fan-out: of these recipients, which have
/// blocked this sender? Returns the recipients to SKIP.
pub async fn blockers_of(
    tx: &mut sqlx::PgConnection,
    sender: Uuid,
    candidates: &[Uuid],
) -> Result<HashSet<Uuid>, (StatusCode, String)> {
    let rows: Vec<(Uuid,)> = sqlx::query_as(
        "SELECT blocker_subject_id FROM chat_blocks
          WHERE blocked_subject_id = $1 AND blocker_subject_id = ANY($2)")
        .bind(sender).bind(candidates).fetch_all(&mut *tx).await.map_err(db::internal)?;
    Ok(rows.into_iter().map(|(id,)| id).collect())
}

Enforcement point 1 - the message list

// services/chat/src/messages.rs :: list

let blocked = blocks::blocked_by(&mut tx, caller).await?;
let is_dm = channel.kind == "direct";

let messages: Vec<Message> = rows
    .into_iter()
    // §1 #6 - in a DM, a blocked sender's messages are not returned at all.
    .filter(|m| !(is_dm && blocked.contains(&m.sender_subject_id)))
    .map(|mut m| {
        // §1 #5 - in a group channel, the row survives; its content does not.
        if blocked.contains(&m.sender_subject_id) {
            m.body = String::new();
            m.attachments = vec![];
            m.reactions = vec![];
            m.blocked_sender = true;
        }
        // §1 #9 - a blocked person's reaction is not counted for the blocker.
        m.reactions.retain(|r| !r.reactor_ids.iter().any(|id| blocked.contains(id)));
        m
    })
    .collect();

Enforcement point 2 - the realtime fan-out

// services/chat/src/realtime.rs
// The per-channel broadcast is one-to-many, so the block check CANNOT live at the send site: it
// has to live at each subscriber. Each socket carries its own blocked-set, refreshed on block /
// unblock (the mutation publishes an invalidation on the subscriber's own control channel).

if sock.blocked.contains(&frame.sender_subject_id) {
    if sock.channel_kind == ChannelKind::Direct { continue; }        // §1 #6 - drop entirely
    frame = frame.redacted();                                        // §1 #5 - collapse
}

Enforcement point 3 - the notification and push fan-out

// services/chat/src/notify.rs :: fan_out
// This is the point that was silently broken before this task: the fan-out selects channel members
// and pushes to their devices. It did not know blocks existed, so a blocked person's message would
// still have arrived on the blocker's lock screen carrying their name and the first line of text.

let mut recipients = channel_members_except(&mut tx, channel_id, sender).await?;
let blockers = blocks::blockers_of(&mut tx, sender, &recipients).await?;   // §1 #4, #9
recipients.retain(|r| !blockers.contains(r));

Enforcement point 4 - the DM list

// services/chat/src/channels.rs :: list
// §1 #6 - a DM with a blocked person leaves the blocker's list entirely while the block stands.

let blocked = blocks::blocked_by(&mut tx, caller).await?;
dms.retain(|dm| !blocked.contains(&dm.partner_subject_id));

Wire shape

#[derive(Serialize)]
pub struct Message {
    // ... existing fields ...
    /// True when the caller has blocked this message's sender AND this is a group channel.
    /// The client renders a collapsed row with a "show anyway" affordance. Never true in a DM,
    /// because a blocked sender's DM messages are not returned at all.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub blocked_sender: bool,
}

§4 - Acceptance criteria

  1. Block is directional and private - after A blocks B, GET /v1/chat/blocks as B returns an empty list, and nothing B can fetch differs from before the block.
  2. Self-block is refused - blocking your own subject id returns 400 and writes no row.
  3. Both mutations are idempotent - blocking twice returns 204 twice and leaves one row; unblocking someone never blocked returns 204 and changes nothing.
  4. Group-channel messages are collapsed, not removed - B's message in a shared channel is returned to A with blocked_sender: true, an empty body, no attachments, no reactions, and its original id and position.
  5. DM messages are removed entirely - B's messages in the A-B DM are absent from A's message list: not collapsed, not flagged, not present.
  6. The DM leaves A's list - the A-B DM does not appear in A's GET /v1/chat/channels while the block stands.
  7. The blocked sender is never told - B's POST of a message to the DM returns 201 exactly as before; B's own message list still contains it; no response field, status code, or timing differs from the unblocked case.
  8. The realtime socket drops it - with A's socket open on the shared channel, a message posted by B produces a redacted frame for A in a group channel and no frame at all in a DM.
  9. No notification, no push - a message from B, including one that @mentions A, produces zero notification rows and zero push sends targeting A's devices.
  10. Reactions from B are not counted - B reacting to a message A can see leaves A's folded reaction count unchanged.
  11. Unblock restores everything in place - after DELETE, A's next fetch returns B's messages from during the block with full bodies, in their original positions, and the DM reappears in A's list.
  12. The moderation queue is unaffected - an administrator who has blocked B still sees B's reported content in full in the TASK-CHAT-269 queue.
  13. Neither party leaves the channel - after A blocks B, both remain members and every other member's view is byte-identical to before.
  14. Cross-tenant isolation holds - a block row in tenant A is invisible to any query run with tenant B's GUC.
  15. Exactly one audit row per mutation - chat.subject_blocked / chat.subject_unblocked, actor = blocker; the idempotent no-op emits nothing.
  16. Both locales render - every block/unblock string resolves in en and vi.

§5 - Verification

// services/chat/tests/blocks.rs

#[tokio::test]
async fn group_message_is_collapsed_dm_message_is_gone() {          // AC 4, 5, 6
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    let dm = app.dm(ALICE, BOB).await;
    let gm = app.post_as(BOB, ch, "group text").await;
    let dmm = app.post_as(BOB, dm, "dm text").await;

    app.block_as(ALICE, BOB).await;

    let group = app.list_as(ALICE, ch).await;
    let m = group.iter().find(|m| m.id == gm).expect("row must survive");
    assert!(m.blocked_sender);
    assert_eq!(m.body, "");
    assert!(m.attachments.is_empty());

    let direct = app.list_as(ALICE, dm).await;
    assert!(direct.iter().all(|m| m.id != dmm), "DM message must be absent, not collapsed");

    let channels = app.channels_as(ALICE).await;
    assert!(!channels.iter().any(|c| c.id == dm), "the DM must leave the list");
}

#[tokio::test]
async fn the_blocked_sender_observes_nothing() {                    // AC 7 - the security property
    let app = harness().await;
    let dm = app.dm(ALICE, BOB).await;

    let before = app.post_as_full(BOB, dm, "one").await;
    app.block_as(ALICE, BOB).await;
    let after = app.post_as_full(BOB, dm, "two").await;

    assert_eq!(before.status(), after.status());                    // both 201
    assert_eq!(before.json_shape(), after.json_shape());            // same fields, same types
    // B still sees both of their own messages.
    let bobs_view = app.list_as(BOB, dm).await;
    assert_eq!(bobs_view.len(), 2);
    // And nothing anywhere in B's world names the block.
    assert!(!app.everything_visible_to(BOB).await.contains("block"));
}

#[tokio::test]
async fn realtime_drops_in_dm_and_redacts_in_group() {              // AC 8
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    let dm = app.dm(ALICE, BOB).await;
    app.block_as(ALICE, BOB).await;

    let mut sock = app.socket_as(ALICE).await;
    app.post_as(BOB, ch, "group").await;
    let frame = sock.next_frame().await.expect("a redacted frame arrives");
    assert!(frame.blocked_sender && frame.body.is_empty());

    app.post_as(BOB, dm, "dm").await;
    assert!(sock.next_frame_timeout(Duration::from_millis(500)).await.is_none(),
            "a DM from a blocked sender must produce NO frame at all");
}

#[tokio::test]
async fn no_notification_and_no_push_not_even_for_a_mention() {     // AC 9 - the point that was broken
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    app.register_device(ALICE, "android", "tok-a").await;
    app.block_as(ALICE, BOB).await;

    app.post_as(BOB, ch, "hey @alice look at this").await;

    assert_eq!(app.notifications_for(ALICE).await.len(), 0);
    assert_eq!(app.pushes_to("tok-a").await.len(), 0);
}

#[tokio::test]
async fn blocked_reactions_are_not_counted() {                      // AC 10
    let app = harness().await;
    let ch  = app.channel("general", &[ALICE, BOB, CAROL]).await;
    let msg = app.post_as(CAROL, ch, "hello").await;
    app.react_as(BOB, msg, "+1").await;
    app.block_as(ALICE, BOB).await;

    let seen = app.list_as(ALICE, ch).await;
    let m = seen.iter().find(|m| m.id == msg).unwrap();
    assert!(m.reactions.is_empty(), "a blocked person's reaction must not be counted");
}

#[tokio::test]
async fn unblock_restores_in_place() {                              // AC 11
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    let a1 = app.post_as(ALICE, ch, "first").await;
    app.block_as(ALICE, BOB).await;
    let b1 = app.post_as(BOB, ch, "during the block").await;
    let a2 = app.post_as(ALICE, ch, "third").await;
    app.unblock_as(ALICE, BOB).await;

    let seen = app.list_as(ALICE, ch).await;
    assert_eq!(seen.iter().map(|m| m.id).collect::<Vec<_>>(), vec![a1, b1, a2]);   // in place
    let m = seen.iter().find(|m| m.id == b1).unwrap();
    assert_eq!(m.body, "during the block");                                        // in full
    assert!(!m.blocked_sender);
}

#[tokio::test]
async fn moderation_queue_ignores_blocks() {                        // AC 12
    let app = harness().await;
    let ch  = app.channel("general", &[ADMIN, BOB]).await;
    let msg = app.post_as(BOB, ch, "reported text").await;
    app.report_as(ADMIN, json!({"target_kind":"message","target_message_id":msg,"reason":"hate"})).await;
    app.block_as(ADMIN, BOB).await;

    let queue = app.moderation_queue_as(ADMIN).await;               // TASK-CHAT-269
    assert_eq!(queue[0].snapshot_body, "reported text");            // full content, block or not
}

#[tokio::test]
async fn idempotent_mutations_and_self_block_guard() {              // AC 2, 3, 15
    let app = harness().await;
    assert_eq!(app.block_as_raw(ALICE, ALICE).await.status(), StatusCode::BAD_REQUEST);

    assert_eq!(app.block_as_raw(ALICE, BOB).await.status(), StatusCode::NO_CONTENT);
    assert_eq!(app.block_as_raw(ALICE, BOB).await.status(), StatusCode::NO_CONTENT);
    assert_eq!(app.block_rows(ALICE).await.len(), 1);
    assert_eq!(app.audit_rows("chat.subject_blocked").await.len(), 1);   // no-op emits nothing

    assert_eq!(app.unblock_as_raw(ALICE, CAROL).await.status(), StatusCode::NO_CONTENT);
    assert_eq!(app.audit_rows("chat.subject_unblocked").await.len(), 0);
}

§6 - Implementation skeleton

(The four enforcement points in §3 are the skeleton. What remains is socket-state invalidation, which is the only genuinely stateful part and the easiest thing to get wrong - see §10 row 5.)

// services/chat/src/blocks.rs :: block
// After committing the block, publish an invalidation to the BLOCKER's own control topic so every
// socket they hold refreshes its blocked-set. Without this, an open tab keeps receiving the blocked
// person's frames until it reconnects - which is a live, observable failure of §1 #4.
state.realtime.invalidate_blocks(blocker).await;

§7 - Dependencies

§8 - Example payloads

POST /v1/chat/blocks
{ "subject_id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4" }
204 No Content

A group-channel message from a blocked sender, as returned to the blocker:

{
  "id": "6b1f4e0a-7c2d-4d19-9b0e-1f2a3c4d5e6f",
  "channel_id": "3f5a1b2c-9d8e-4c7b-a6f5-e4d3c2b1a098",
  "sender_subject_id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4",
  "body": "",
  "attachments": [],
  "reactions": [],
  "blocked_sender": true,
  "created_at": "2026-07-11T05:22:19Z"
}

The same message, as returned to everyone else in the channel - unchanged:

{
  "id": "6b1f4e0a-7c2d-4d19-9b0e-1f2a3c4d5e6f",
  "body": "the actual text",
  "attachments": [ { "id": "…", "filename": "shot.png" } ],
  "reactions": [ { "emoji": "+1", "count": 2 } ],
  "created_at": "2026-07-11T05:22:19Z"
}

The audit row:

{
  "event_type": "chat.subject_blocked",
  "payload": { "blocked_subject_id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4" }
}

§9 - Open questions

Deferred:

§10 - Failure modes inventory

FailureDetectionOutcomeRecovery
Block implemented only in the clientAC 4, 5, 8, 9 all assert on the server responseContent would still cross the wire and be visible in the network tabEvery enforcement point is server-side; the client never filters
Notification fan-out ignores blocksAC 9 asserts zero pushes, including for an @mentionBlocked person's name and text on the blocker's lock screenblockers_of is applied in notify.rs before recipient retention
Blocked sender receives a 403 and learns they are blockedAC 7 asserts identical status and body shape before and afterEscalation to another channel; the exact harm the block exists to preventSilent-drop delivery model (§1 #7); no error path exists
Timing side-channel discloses the blockDelivery path length is the same either way (the message is written normally)Blocked person cannot infer the block from latencyThe block is applied at read fan-out, not at write
Open WebSocket keeps delivering after the block landsAC 8 with a socket opened before the blockBlocker keeps seeing frames until they reconnectinvalidate_blocks publishes to the blocker's control topic on mutation (§6)
Blocked-set fetched per message (N+1)Message list is the hot path; one HashSet per requestLatency regression on every channel openblocked_by is read once per request and threaded through
Reaction from a blocked person leaks their existence via a countAC 10Blocker sees "2 reactions" where they should see 1Reactions are filtered by reactor id, not just message sender
Blocker blocks themselveschat_blocks_not_self CHECK400Constraint is in the DB, not only the handler
Unblock loses messages sent during the blockAC 11 asserts exact ordering and full bodiesSilent data loss from the blocker's viewNothing is deleted; the block filters reads only
Moderation queue applies the block and hides the evidenceAC 12Admin cannot adjudicate a report about someone they blockedTASK-CHAT-269 does not call blocked_by
Block leaks to other channel membersAC 13 asserts byte-identical views for third partiesSocial fallout inside a small workspaceThe block is applied per-caller, never at write
Cross-tenant block row readRLS USING + WITH CHECK, FORCEd; AC 14Zero rowsPolicy mirrors every other chat table
A fifth fan-out path is added later and forgets blocks§1 #4 enumerates the four; each has an ACA new leakThe enumeration is the checklist; a new path needs a new AC
DM channel accumulates undelivered messages during a long blockStorage only; the blocker never reads themUnbounded-ish growth in a pathological caseBounded by the existing per-message size cap and by retention (TASK-CHAT-240)

§11 - Implementation notes

End of TASK-CHAT-268.

Audit

§1 - Verdict summary

TASK-CHAT-268 specifies user blocking: 15 normative §1 clauses, one migration (chat_blocks, directional, RLS-scoped), three endpoints, four named server-side enforcement points, 16 acceptance criteria, 8 Rust integration tests, and a 14-row failure-mode inventory. Eight findings were raised across three rounds; all eight are resolved. Two of them - ISS-001 (notification fan-out) and ISS-002 (403 to the blocked sender) - were not cosmetic: the first would have shipped a "block" that still lit up the blocker's lock screen with the blocked person's name, and the second would have shipped a block that actively endangers the person it is meant to protect.

§2 - Findings (all resolved)

ISS-001 - The block would have been defeated by the push notification

The first draft enforced blocking in the message-list query and nowhere else, which reads as complete and is not. notify.rs::fan_out selects channel members and pushes to their registered devices; it has no notion of blocks. So the blocked person posts, the message never appears in the blocker's list - and a push notification carrying the blocked person's display name and the first line of their message lands on the blocker's lock screen anyway. The most visible surface in the product would have been the one that leaked. Resolved: §1 #4 enumerates four enforcement points as normative (list, realtime, notification/push, DM list), §3 shows blockers_of applied inside notify.rs before recipient retention, and AC #9 asserts zero notifications and zero pushes including for an @mention. §10 row 2.

ISS-002 - Returning 403 to the blocked sender endangers the blocker

The draft refused the blocked person's message with 403 Forbidden. Honest, simple, and the wrong call: it tells a harasser they have been blocked, which is a documented escalation trigger - the person who was being ignored becomes a person who knows they were rejected, and moves to another channel. Resolved: §1 #7 and #8 mandate the silent-drop model (the message is persisted, the sender sees it in their own client, it is simply never delivered), and §1 #8 extends the prohibition to every observable: status code, error string, read receipt, delivery indicator. §2 explains why at length. AC #7 asserts identical status and body shape before and after the block, and asserts nothing in B's entire visible world names the block. §10 rows 3 and 4.

ISS-003 - A timing side-channel would have reinstated the disclosure

Having removed the 403, an obvious implementation applies the block at write time - look up the recipient's blocks, and skip the write. That is measurably faster than the unblocked path, so the block becomes inferable from latency, which re-opens ISS-002 through the back door. Resolved: §1 #7 requires the message to be persisted normally, and the block is applied at read fan-out. §10 row 4 names the failure and the fix.

ISS-004 - Deleting a blocked person's messages would have corrupted the conversation

The draft removed blocked senders' messages from every channel. In a group channel that silently rewrites history for one participant: replies to a now-absent message become nonsense, thread counts stop matching, and the blocker ends up more confused than protected. Resolved: §1 #5 collapses rather than removes in group channels - the row keeps its id and position, the content is withheld, and a blocked_sender flag drives a client placeholder with an explicit "show anyway". §1 #6 keeps removal for DMs, where there is no surrounding conversation to contextualise and a column of placeholders is just a drip-feed of the harassment. §2 argues both halves. AC #4 and AC #5.

ISS-005 - An already-open WebSocket would have kept delivering after the block landed

Each socket caches its owner's blocked-set at connect. Nothing in the draft invalidated it, so a block placed in one tab left every other open tab receiving the blocked person's frames until it happened to reconnect - a live, observable failure of §1 #4 that no unit test on the HTTP layer would catch. Resolved: §6 publishes invalidate_blocks(blocker) to the blocker's own control topic on every mutation; §11 explains why the blocker's topic and not the channel's (a block is nobody else's business). AC #8 opens the socket before the block to force the path. §10 row 5.

ISS-006 - Reaction counts would have leaked the blocked person's presence

Blocking the sender of a message says nothing about a blocked person reacting to someone else's message. The draft filtered by message sender only, so a blocker would see "2 reactions" on a message where one of the two was from the person they blocked - which both leaks their activity and is quietly maddening. Resolved: §1 #9 suppresses reactions and mentions authored by a blocked person; §3's message-list enforcement retains the folded reaction set by reactor id, not sender id. AC #10.

ISS-007 - The moderation queue would have been blinded by the reviewer's own block

The likeliest person to report someone is the same person who blocked them, and in a small workspace that is often the administrator. If blocked_by were applied uniformly, the admin would open TASK-CHAT-269's queue and find the reported content redacted - by their own block. Resolved: §1 #12 carves the moderation queue out explicitly, §7 makes it a normative constraint on TASK-CHAT-269 rather than a note, and AC #12 asserts the queue renders the full snapshot for an admin who has blocked the reported person. §10 row 10.

ISS-008 - N+1 on the hottest endpoint in the service

The first cut queried chat_blocks per message while mapping the list. The message list is the single most-called endpoint in chat, and this would have added one round trip per row. Resolved: §3 reads the blocked-set once per request into a HashSet<Uuid> and threads it through all four enforcement points; blockers_of takes the candidate recipient list so the notification fan-out asks one indexed question rather than scanning. §11 documents both. §10 row 6.

§3 - Resolution

Eight findings, all resolved. The spec's central insight - that a block is not one filter but four, and that the blocked person must observe nothing - is now pinned by acceptance criteria rather than by prose: AC #9 fails if a future contributor adds a notification path that forgets blocks, AC #7 fails if anyone "helpfully" restores a 403, and AC #8 fails if socket invalidation is dropped.

Score = 10/10.


End of TASK-CHAT-268 audit.