Task — engineering-spec@1

"In-app content reporting - report a message, an attachment, or a person"

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

§1 - Description (BCP-14 normative)

  1. The chat service MUST expose POST /v1/chat/reports, authenticated by the same CyberOS token as every other chat route, which records a report against one of three target kinds: a message, an attachment, or a person (a subject).
  1. A report MUST carry a reason drawn from a closed set: spam | harassment | hate | sexual | violence | self_harm | illegal | other. The set is closed because TASK-CHAT-269's moderation queue groups and prioritises by reason, and a free-text reason cannot be grouped. A caller-supplied detail free-text field MAY accompany it, capped at 1000 characters.
  1. The reporter MUST be a member of the channel that contains the reported message or attachment. A person who cannot see the content cannot report it. Reporting a subject (a person) does not require co-membership of any channel, because harassment can arrive by DM from someone whose channels you do not share.
  1. On accepting a report the service MUST persist an immutable snapshot of the reported content: the message body (or the attachment's filename, content type, and size) exactly as it stood at report time. It MUST NOT rely on reading the content back at review time. The sender can edit or soft-delete a message after it is reported, and a moderation queue that renders "(deleted)" for every report it receives is not a moderation queue.
  1. A report MUST NOT notify, or in any way become visible to, the reported person. The response body MUST NOT disclose whether the same target has been reported before, and MUST NOT carry any reporter-identifying field.
  1. The service MUST deduplicate open reports: at most one open report may exist per (tenant_id, reporter_subject_id, target) triple. A second submission against the same target by the same reporter while a report is still open MUST return 200 OK with the existing report's id, not 409. From the reporter's point of view pressing Report twice is not an error, and a 409 leaks that a prior report exists.
  1. The service MUST rate-limit report creation to 20 reports per subject per rolling hour, returning 429 Too Many Requests beyond that. The limit exists to make report-spam as a harassment vector uneconomic, not to ration legitimate use; 20/hour is far above any honest rate.
  1. Every accepted report MUST emit exactly one chat.report_created audit row via audit::emit, carrying report_id, target_kind, reason, channel_id (nullable), and the reporter's subject_id as the actor. It MUST NOT carry the snapshot body: the audit chain is replicated to the memory module, and copying reported content into it doubles the blast radius of the very content someone asked us to remove.
  1. Reports MUST be tenant-scoped and protected by row-level security with both USING and WITH CHECK, in line with every other chat table. A report raised in one workspace MUST NOT be readable from another under any query.
  1. The web client MUST offer a report entry point in two places: the message overflow menu (for a message, and for its attachment if it has one), and the member list / profile popover (for a person). Both MUST open the same dialog.
  1. The report dialog MUST be reachable by keyboard alone, MUST trap focus while open, and MUST announce its result to assistive technology. A moderation control that only a mouse user can reach is not a moderation control.
  1. Every string the dialog renders MUST ship in both en and vi. A Vietnamese-speaking employee reporting harassment in English is a failure of the product, not of the employee.
  1. The service MUST NOT take any automated action on the reported content. It does not hide, delete, or flag the message. It records. Deciding what happens is TASK-CHAT-269's job and a human's decision.
  1. The client SHOULD confirm submission with a non-blocking toast and close the dialog. It MUST NOT render the report's id, status, or any downstream state to the reporter; there is no user-facing report history in this slice.

§2 - Why this design (rationale for humans)

Why a closed reason set (§1 #2)? Google's UGC policy is satisfied by "a mechanism to report", but the mechanism has to be usable by whoever reviews the report. TASK-CHAT-269 sorts an admin's queue by severity, and severity is a function of reason. Free text cannot be sorted. The set chosen mirrors the categories every major platform converged on, so it maps cleanly onto the content-rating questionnaire's own vocabulary.

Why snapshot the content at report time (§1 #4)? This is the clause the whole task turns on. chat_messages supports edit (edited_at) and soft delete (deleted_at), both available to the sender. Without a snapshot, the obvious abuse is: post something abusive, wait for the report, delete it, and the moderation queue shows an empty row while the recipient has already read it. The snapshot is the evidence. It is written once and never updated.

Why does a second report return 200, not 409 (§1 #6)? Two reasons, one usability and one security. Usability: a user who is not sure their tap registered will tap again, and greeting that with an error teaches them the feature is broken. Security: a distinct response for "already reported" is an oracle. Anyone could probe whether a given message has an outstanding report by reporting it and reading the status code. Returning the same shape either way closes that.

Why can you report a person without sharing a channel (§1 #3)? Because the DM path exists. chat_channels.kind = 'direct' lets any workspace member open a DM with any other. If reporting required co-membership of a group channel, the one place harassment is most likely - a DM from someone you do not work with - would be the one place you could not report it.

Why exclude the snapshot from the audit row (§1 #8)? The audit chain is hash-chained and replicated into the memory module, where it is designed to be durable and hard to rewrite. That is exactly right for "who did what when", and exactly wrong for a copy of content someone has asked us to consider removing. The audit row records that a report happened; the report row holds the evidence, under RLS, deletable when the report is resolved.

Why no automated action (§1 #13)? Auto-hiding on report is a self-service censorship button: one person can silence another with a tap. In an invite-only workspace of colleagues the correct arbiter is the workspace administrator, who knows the people involved. This also keeps CyberSkill out of the position of adjudicating a customer's internal dispute, which is what the published privacy policy already promises ("your organisation is the controller").

Why rate-limit at 20/hour (§1 #7)? The report table is writable by any authenticated member, so it is an amplification surface: a malicious member could bury an admin's queue under thousands of rows. 20/hour is roughly two orders of magnitude above the observed rate of any honest reporter and still bounds the queue.

§3 - API contract

Migration

-- services/chat/migrations/0013_chat_reports.sql
-- TASK-CHAT-267: in-app content reporting. One row per report. The snapshot columns are written once at
-- INSERT and never updated: the reported message can be edited or soft-deleted by its sender afterwards,
-- and a moderation queue that renders "(deleted)" for every row is not a moderation queue.

CREATE TABLE IF NOT EXISTS chat_reports (
    id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id             UUID NOT NULL,
    reporter_subject_id   UUID NOT NULL,

    target_kind           TEXT NOT NULL,
    target_message_id     UUID NULL REFERENCES chat_messages(id)    ON DELETE SET NULL,
    target_attachment_id  UUID NULL REFERENCES chat_attachments(id) ON DELETE SET NULL,
    target_subject_id     UUID NULL,
    channel_id            UUID NULL REFERENCES chat_channels(id)    ON DELETE SET NULL,

    reason                TEXT NOT NULL,
    detail                TEXT NULL,

    -- Evidence. Written at INSERT, never updated. See §1 #4.
    snapshot_body         TEXT NULL,
    snapshot_filename     TEXT NULL,
    snapshot_content_type TEXT NULL,
    snapshot_size_bytes   BIGINT NULL,
    snapshot_sender_id    UUID NULL,
    snapshot_taken_at     TIMESTAMPTZ NOT NULL DEFAULT now(),

    status                TEXT NOT NULL DEFAULT 'open',
    resolution            TEXT NULL,
    resolved_at           TIMESTAMPTZ NULL,
    resolved_by_subject_id UUID NULL,

    created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),

    CONSTRAINT chat_reports_target_kind_enum
        CHECK (target_kind IN ('message', 'attachment', 'subject')),
    CONSTRAINT chat_reports_reason_enum
        CHECK (reason IN ('spam','harassment','hate','sexual','violence','self_harm','illegal','other')),
    CONSTRAINT chat_reports_status_enum
        CHECK (status IN ('open', 'actioned', 'dismissed')),
    CONSTRAINT chat_reports_detail_len
        CHECK (detail IS NULL OR char_length(detail) <= 1000),
    -- Exactly one target column is populated, and it matches target_kind.
    CONSTRAINT chat_reports_target_shape CHECK (
        (target_kind = 'message'    AND target_message_id    IS NOT NULL
                                    AND target_attachment_id IS NULL AND target_subject_id IS NULL) OR
        (target_kind = 'attachment' AND target_attachment_id IS NOT NULL
                                    AND target_message_id    IS NULL AND target_subject_id IS NULL) OR
        (target_kind = 'subject'    AND target_subject_id    IS NOT NULL
                                    AND target_message_id    IS NULL AND target_attachment_id IS NULL)
    ),
    -- A reporter cannot report themselves. Cheap guard against a confused client.
    CONSTRAINT chat_reports_not_self
        CHECK (target_subject_id IS NULL OR target_subject_id <> reporter_subject_id)
);

-- §1 #6: at most one OPEN report per (tenant, reporter, target). Resolved reports do not block a
-- new one - the same person can misbehave twice.
CREATE UNIQUE INDEX IF NOT EXISTS chat_reports_open_uniq
    ON chat_reports (tenant_id, reporter_subject_id, target_kind,
                     COALESCE(target_message_id,    '00000000-0000-0000-0000-000000000000'::uuid),
                     COALESCE(target_attachment_id, '00000000-0000-0000-0000-000000000000'::uuid),
                     COALESCE(target_subject_id,    '00000000-0000-0000-0000-000000000000'::uuid))
    WHERE status = 'open';

CREATE INDEX IF NOT EXISTS chat_reports_queue_idx
    ON chat_reports (tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS chat_reports_rate_idx
    ON chat_reports (reporter_subject_id, created_at DESC);

ALTER TABLE chat_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE chat_reports FORCE  ROW LEVEL SECURITY;
CREATE POLICY chat_reports_tenant_scoped ON chat_reports
    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, UPDATE ON chat_reports TO cyberos_app;
GRANT SELECT ON chat_reports TO cyberos_ro;

Types

// services/chat/src/reports.rs

use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::{audit, auth, db, AppState};

/// Reports are created at 20/subject/hour (§1 #7). Above that the endpoint 429s.
const REPORT_RATE_LIMIT_PER_HOUR: i64 = 20;
const DETAIL_MAX_CHARS: usize = 1000;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetKind {
    Message,
    Attachment,
    Subject,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
    Spam,
    Harassment,
    Hate,
    Sexual,
    Violence,
    SelfHarm,
    Illegal,
    Other,
}

impl Reason {
    /// Explicit metric/audit label. NEVER Debug-format an enum into a label
    /// (discipline §8.6a): Debug output is not a stable wire format.
    pub fn as_label(&self) -> &'static str {
        match self {
            Reason::Spam => "spam",
            Reason::Harassment => "harassment",
            Reason::Hate => "hate",
            Reason::Sexual => "sexual",
            Reason::Violence => "violence",
            Reason::SelfHarm => "self_harm",
            Reason::Illegal => "illegal",
            Reason::Other => "other",
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct CreateReport {
    pub target_kind: TargetKind,
    #[serde(default)]
    pub target_message_id: Option<Uuid>,
    #[serde(default)]
    pub target_attachment_id: Option<Uuid>,
    #[serde(default)]
    pub target_subject_id: Option<Uuid>,
    pub reason: Reason,
    #[serde(default)]
    pub detail: Option<String>,
}

/// The ONLY thing the reporter gets back. No status, no history, no count.
/// §1 #5: the response must not become an oracle for prior reports.
#[derive(Debug, Serialize)]
pub struct ReportAccepted {
    pub id: Uuid,
}

Endpoint

POST /v1/chat/reports
Authorization: Bearer <cyberos token>
Content-Type: application/json

201 Created  { "id": "<uuid>" }   -- new report
200 OK       { "id": "<uuid>" }   -- an open report by this reporter against this target already exists
400          "unknown reason" | "detail is too long" | "target shape does not match target_kind"
401          token invalid
403          "not a channel member"   -- message/attachment targets only
404          "no such message" | "no such attachment"
429          "too many reports"

Handler skeleton

pub async fn create(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(req): Json<CreateReport>,
) -> Result<(StatusCode, Json<ReportAccepted>), (StatusCode, String)> {
    let claims = auth::verify(&state, &headers)
        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
    let tenant = claims.tenant_id;
    let reporter = claims.subject_id;

    if let Some(d) = req.detail.as_deref() {
        if d.chars().count() > DETAIL_MAX_CHARS {
            return Err((StatusCode::BAD_REQUEST, "detail is too long".into()));
        }
    }

    let mut tx = db::begin_tenant(&state.pool, tenant).await?;

    // §1 #7 - rate limit BEFORE any target lookup, so a rate-limited caller cannot use the
    // endpoint's 403/404 responses to probe which message ids exist.
    let recent: i64 = sqlx::query_scalar(
        "SELECT count(*) FROM chat_reports
          WHERE reporter_subject_id = $1 AND created_at > now() - interval '1 hour'",
    )
    .bind(reporter)
    .fetch_one(&mut *tx)
    .await
    .map_err(db::internal)?;
    if recent >= REPORT_RATE_LIMIT_PER_HOUR {
        return Err((StatusCode::TOO_MANY_REQUESTS, "too many reports".into()));
    }

    // Resolve the target, enforce membership (§1 #3), and take the snapshot (§1 #4) in the
    // SAME transaction that inserts, so the snapshot cannot race an edit/delete.
    let snap = snapshot_target(&mut tx, tenant, reporter, &req).await?;

    let row: Option<(Uuid,)> = sqlx::query_as(
        "INSERT INTO chat_reports
            (tenant_id, reporter_subject_id, target_kind, target_message_id, target_attachment_id,
             target_subject_id, channel_id, reason, detail, snapshot_body, snapshot_filename,
             snapshot_content_type, snapshot_size_bytes, snapshot_sender_id)
         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
         ON CONFLICT DO NOTHING
         RETURNING id",
    )
    .bind(tenant).bind(reporter)
    .bind(kind_str(req.target_kind))
    .bind(req.target_message_id).bind(req.target_attachment_id).bind(req.target_subject_id)
    .bind(snap.channel_id)
    .bind(req.reason.as_label()).bind(req.detail.as_deref())
    .bind(snap.body.as_deref()).bind(snap.filename.as_deref())
    .bind(snap.content_type.as_deref()).bind(snap.size_bytes)
    .bind(snap.sender_id)
    .fetch_optional(&mut *tx)
    .await
    .map_err(db::internal)?;

    // §1 #6 - the partial unique index fired: an open report already exists. Return it with 200,
    // NOT 409. Same shape either way; the caller learns nothing it did not already know.
    let (id, code) = match row {
        Some((id,)) => (id, StatusCode::CREATED),
        None => (existing_open_report(&mut *tx, tenant, reporter, &req).await?, StatusCode::OK),
    };

    tx.commit().await.map_err(db::internal)?;

    // §1 #8 - audit AFTER commit, and WITHOUT the snapshot body.
    audit::emit(
        &state, tenant, reporter, "chat.report_created",
        json!({
            "report_id":   id,
            "target_kind": kind_str(req.target_kind),
            "reason":      req.reason.as_label(),
            "channel_id":  snap.channel_id,
        }),
    ).await;

    Ok((code, Json(ReportAccepted { id })))
}

Route registration

// services/chat/src/lib.rs
    .route("/v1/chat/reports", post(reports::create))

§4 - Acceptance criteria

  1. A message report is stored with its snapshot - posting a report against a message persists snapshot_body equal to the message body at that instant, and snapshot_sender_id equal to its sender.
  2. The snapshot survives a delete - soft-deleting the reported message afterwards leaves chat_reports.snapshot_body unchanged and non-null.
  3. The snapshot survives an edit - editing the reported message afterwards leaves chat_reports.snapshot_body holding the original text.
  4. Non-members are refused - a subject who is not a member of the message's channel receives 403, and no row is written.
  5. A subject report needs no shared channel - reporting a person with whom the reporter shares no channel succeeds with 201.
  6. Self-report is refused - target_kind: subject with target_subject_id == reporter is rejected by the DB constraint and surfaces as 400.
  7. Duplicate reports return 200, not 409 - a second report by the same reporter against the same target while the first is open returns 200 with the first report's id, and creates no second row.
  8. A resolved report does not block a new one - after the first report is set to dismissed, the same reporter can raise a new open report against the same target.
  9. Rate limit fires at 20/hour - the 21st report by one subject inside an hour returns 429, and the check runs before any target lookup.
  10. Exactly one audit row per accepted report - chat.report_created is emitted once, carries report_id, target_kind, reason, and does not carry snapshot_body or detail.
  11. No audit row on a rejected report - a 400/403/404/429 emits nothing.
  12. Cross-tenant isolation holds - a report created in tenant A is invisible to any query executed with tenant B's GUC set, including a direct SELECT *.
  13. An unknown reason is rejected - a body carrying reason: "because" returns 400 and writes nothing.
  14. Detail is capped - a 1001-character detail returns 400.
  15. The dialog is keyboard-operable - the report dialog can be opened, filled, submitted, and dismissed without a pointer, and focus returns to the invoking control on close.
  16. Both locales render - every string in the dialog resolves in en and in vi; no key falls back to its own name.

§5 - Verification

// services/chat/tests/reports.rs

#[tokio::test]
async fn snapshot_survives_delete_and_edit() {                      // AC 1, 2, 3
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    let msg = app.post_as(BOB, ch, "something abusive").await;

    let r = app.report_as(ALICE, json!({
        "target_kind": "message", "target_message_id": msg, "reason": "harassment"
    })).await;
    assert_eq!(r.status(), StatusCode::CREATED);

    app.edit_as(BOB, msg, "actually something nice").await;
    app.delete_as(BOB, msg).await;

    let (body, sender): (String, Uuid) = sqlx::query_as(
        "SELECT snapshot_body, snapshot_sender_id FROM chat_reports WHERE target_message_id = $1")
        .bind(msg).fetch_one(app.pool()).await.unwrap();
    assert_eq!(body, "something abusive");   // NOT the edited text, NOT null
    assert_eq!(sender, BOB);
}

#[tokio::test]
async fn non_member_cannot_report_a_message() {                     // AC 4
    let app = harness().await;
    let ch = app.channel("private", &[BOB]).await;
    let msg = app.post_as(BOB, ch, "hello").await;

    let r = app.report_as(ALICE, json!({
        "target_kind": "message", "target_message_id": msg, "reason": "spam"
    })).await;
    assert_eq!(r.status(), StatusCode::FORBIDDEN);
    assert_eq!(app.count_reports().await, 0);
}

#[tokio::test]
async fn subject_report_needs_no_shared_channel() {                 // AC 5
    let app = harness().await;                                      // ALICE and BOB share nothing
    let r = app.report_as(ALICE, json!({
        "target_kind": "subject", "target_subject_id": BOB, "reason": "harassment"
    })).await;
    assert_eq!(r.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn duplicate_report_is_idempotent_not_conflict() {            // AC 7
    let app = harness().await;
    let ch = app.channel("general", &[ALICE, BOB]).await;
    let msg = app.post_as(BOB, ch, "spam spam spam").await;
    let body = json!({"target_kind":"message","target_message_id":msg,"reason":"spam"});

    let first  = app.report_as(ALICE, body.clone()).await;
    let second = app.report_as(ALICE, body.clone()).await;

    assert_eq!(first.status(),  StatusCode::CREATED);
    assert_eq!(second.status(), StatusCode::OK);          // NOT 409 - see §2
    assert_eq!(first.json::<Value>().await["id"], second.json::<Value>().await["id"]);
    assert_eq!(app.count_reports().await, 1);
}

#[tokio::test]
async fn resolved_report_does_not_block_a_new_one() {               // AC 8
    let app = harness().await;
    let ch  = app.channel("general", &[ALICE, BOB]).await;
    let msg = app.post_as(BOB, ch, "again").await;
    let body = json!({"target_kind":"message","target_message_id":msg,"reason":"spam"});

    app.report_as(ALICE, body.clone()).await;
    app.set_report_status(msg, "dismissed").await;

    let again = app.report_as(ALICE, body).await;
    assert_eq!(again.status(), StatusCode::CREATED);
    assert_eq!(app.count_reports().await, 2);
}

#[tokio::test]
async fn rate_limit_fires_before_target_lookup() {                  // AC 9
    let app = harness().await;
    for i in 0..20 {
        let r = app.report_as(ALICE, json!({
            "target_kind":"subject","target_subject_id": app.filler_subject(i),"reason":"spam"
        })).await;
        assert_eq!(r.status(), StatusCode::CREATED);
    }
    // 21st, against a message id that does NOT exist. If the limit were checked after the
    // lookup this would 404 and leak that the id is unknown.
    let r = app.report_as(ALICE, json!({
        "target_kind":"message","target_message_id": Uuid::new_v4(),"reason":"spam"
    })).await;
    assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
}

#[tokio::test]
async fn audit_row_is_emitted_once_and_carries_no_content() {       // AC 10, 11
    let app = harness().await;
    let ch  = app.channel("general", &[ALICE, BOB]).await;
    let msg = app.post_as(BOB, ch, "abusive text here").await;

    app.report_as(ALICE, json!({
        "target_kind":"message","target_message_id":msg,"reason":"hate","detail":"private note"
    })).await;

    let rows = app.audit_rows("chat.report_created").await;
    assert_eq!(rows.len(), 1);
    let p = &rows[0]["payload"];
    assert_eq!(p["reason"], "hate");
    assert_eq!(p["target_kind"], "message");
    assert!(p.get("snapshot_body").is_none());
    assert!(p.get("detail").is_none());
    assert!(!rows[0].to_string().contains("abusive text here"));
    assert!(!rows[0].to_string().contains("private note"));

    // Rejected reports emit nothing.
    app.report_as(ALICE, json!({"target_kind":"message",
        "target_message_id": Uuid::new_v4(), "reason":"spam"})).await;
    assert_eq!(app.audit_rows("chat.report_created").await.len(), 1);
}

#[tokio::test]
async fn reports_are_tenant_isolated() {                            // AC 12
    let app = harness().await;
    let id  = app.seed_report_in(TENANT_A).await;
    let leaked: Option<(Uuid,)> = app.as_tenant(TENANT_B, |tx| {
        sqlx::query_as("SELECT id FROM chat_reports WHERE id = $1").bind(id).fetch_optional(tx)
    }).await.unwrap();
    assert!(leaked.is_none());
}

#[tokio::test]
async fn closed_enums_and_caps_are_enforced() {                     // AC 6, 13, 14
    let app = harness().await;
    assert_eq!(app.report_raw(ALICE, r#"{"target_kind":"message","target_message_id":"…","reason":"because"}"#)
        .await.status(), StatusCode::BAD_REQUEST);
    assert_eq!(app.report_as(ALICE, json!({
        "target_kind":"subject","target_subject_id": ALICE, "reason":"spam"})).await.status(),
        StatusCode::BAD_REQUEST);
    assert_eq!(app.report_as(ALICE, json!({
        "target_kind":"subject","target_subject_id": BOB, "reason":"spam",
        "detail": "x".repeat(1001)})).await.status(), StatusCode::BAD_REQUEST);
}
// apps/web/src/components/__tests__/ReportDialog.test.tsx          // AC 15, 16
test("dialog is operable by keyboard alone and returns focus", async () => {
  const { getByRole } = render(<MessageRow message={msg} />);
  const trigger = getByRole("button", { name: /more actions/i });
  trigger.focus();
  await userEvent.keyboard("{Enter}");
  await userEvent.keyboard("{ArrowDown}{Enter}");         // Report
  expect(getByRole("dialog", { name: /report/i })).toHaveFocus();
  await userEvent.keyboard("{Escape}");
  expect(trigger).toHaveFocus();
});

test("every dialog string resolves in en and vi", () => {
  for (const locale of ["en", "vi"] as const) {
    for (const key of REPORT_DIALOG_KEYS) {
      expect(t(locale, key)).not.toEqual(key);            // no key-as-fallback
    }
  }
});

§6 - Implementation skeleton

(The API contract in §3 is the skeleton. snapshot_target is the only helper worth spelling out, because the membership check and the snapshot must happen in the same transaction as the insert - see §10 row 3.)

struct Snapshot {
    channel_id:   Option<Uuid>,
    body:         Option<String>,
    filename:     Option<String>,
    content_type: Option<String>,
    size_bytes:   Option<i64>,
    sender_id:    Option<Uuid>,
}

async fn snapshot_target(
    tx: &mut sqlx::PgConnection,
    tenant: Uuid,
    reporter: Uuid,
    req: &CreateReport,
) -> Result<Snapshot, (StatusCode, String)> {
    match req.target_kind {
        TargetKind::Message => {
            let id = req.target_message_id
                .ok_or((StatusCode::BAD_REQUEST, "target shape does not match target_kind".to_string()))?;
            // FOR SHARE: hold the message row for the life of the tx so a concurrent edit cannot
            // land between the read and the insert (§10 row 3).
            let row: Option<(Uuid, Uuid, String)> = sqlx::query_as(
                "SELECT channel_id, sender_subject_id, body FROM chat_messages
                  WHERE id = $1 AND tenant_id = $2 FOR SHARE")
                .bind(id).bind(tenant).fetch_optional(&mut *tx).await.map_err(db::internal)?;
            let (channel_id, sender, body) = row
                .ok_or((StatusCode::NOT_FOUND, "no such message".to_string()))?;
            require_member(&mut *tx, channel_id, reporter).await?;   // §1 #3 -> 403
            Ok(Snapshot { channel_id: Some(channel_id), body: Some(body),
                          sender_id: Some(sender), filename: None,
                          content_type: None, size_bytes: None })
        }
        TargetKind::Attachment => { /* same shape against chat_attachments */ }
        TargetKind::Subject => {
            let id = req.target_subject_id
                .ok_or((StatusCode::BAD_REQUEST, "target shape does not match target_kind".to_string()))?;
            // No membership check by design (§1 #3). No snapshot: the target IS the person.
            let _ = id;
            Ok(Snapshot { channel_id: None, body: None, filename: None,
                          content_type: None, size_bytes: None, sender_id: None })
        }
    }
}

§7 - Dependencies

§8 - Example payloads

Report a message:

POST /v1/chat/reports
{
  "target_kind": "message",
  "target_message_id": "6b1f4e0a-7c2d-4d19-9b0e-1f2a3c4d5e6f",
  "reason": "harassment",
  "detail": "Third time this week after I asked him to stop."
}

201 Created
{ "id": "9d2c8f31-0b44-4a6e-8f77-2c1e5a9b3d40" }

Report a person, no shared channel:

POST /v1/chat/reports
{ "target_kind": "subject",
  "target_subject_id": "a0b1c2d3-e4f5-4607-8899-aabbccddeeff",
  "reason": "spam" }

201 Created
{ "id": "1a2b3c4d-5e6f-4708-99aa-bbccddeeff00" }

The audit row (note: no snapshot_body, no detail):

{
  "event_type": "chat.report_created",
  "payload": {
    "report_id":   "9d2c8f31-0b44-4a6e-8f77-2c1e5a9b3d40",
    "target_kind": "message",
    "reason":      "harassment",
    "channel_id":  "3f5a1b2c-9d8e-4c7b-a6f5-e4d3c2b1a098"
  }
}

The stored row, after the reported message has been edited and deleted by its sender:

{
  "id": "9d2c8f31-0b44-4a6e-8f77-2c1e5a9b3d40",
  "status": "open",
  "reason": "harassment",
  "snapshot_body": "the original abusive text",
  "snapshot_sender_id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4",
  "snapshot_taken_at": "2026-07-11T05:14:22Z"
}

§9 - Open questions

Deferred:

§10 - Failure modes inventory

FailureDetectionOutcomeRecovery
Reporter edits/deletes the message between the read and the insertFOR SHARE lock on the message row for the life of the txSnapshot is consistent with the row the reporter sawNone needed; the lock is the fix
Sender deletes the message after the report landssnapshot_body is never updatedModeration queue still shows the evidenceNone needed; the snapshot is the record
Reported message is hard-deleted (channel dropped)ON DELETE SET NULL on target_message_idReport survives with a null target but a live snapshotQueue renders from the snapshot, flags "original removed"
Reporter double-taps ReportPartial unique index on open reports200 with the first report's id; one rowNone needed
Report-spam as a harassment vectorRate limit counted per reporter per hour429 after 20/hourAdmin sees the reporter's volume in the queue and can act
Rate-limit check placed after target lookupAC 9 asserts a 429, not a 404, on an unknown idWould leak message-id existence to a rate-limited callerCheck is first statement in the tx
Report used as an existence oracle for messagesBoth 403 (not a member) and 404 (no such id) exist and are distinguishableA non-member could distinguish "exists but private" from "does not exist"Accepted risk, bounded: membership is workspace-wide and the id space is a v4 UUID; brute-forcing it is not a practical attack. Revisit if channels ever become cross-tenant.
Reported person learns they were reportedNo notification path is wired; response carries no reporter fieldReported person learns nothingEnforced by AC 10 (audit payload) and by there being no fan-out
Snapshot content copied into the hash-chained audit logAC 10 asserts the payload has no snapshot_body / detailReported content stays in one deletable placeFails the test if a future edit adds it
Audit emit fails (memory pool down)audit::emit logs a warning and returnsReport is still created; audit row is lostBest-effort by existing module convention; the report row is the durable record
Cross-tenant read of a reportRLS USING + WITH CHECK; AC 12Zero rowsPolicy is FORCEd, so even the table owner is subject to it
Client sends target_kind: message with a subject idchat_reports_target_shape CHECK400Constraint is in the DB, not just the handler
A confused client reports the reporterchat_reports_not_self CHECK400Constraint is in the DB
detail used to smuggle a payload into the admin UICap at 1000 chars; React escapes on renderNo injectionQueue renders detail as text, never as HTML (TASK-CHAT-269 AC)
Reports pile up unresolvedchat_reports_queue_idx on (tenant, status, created_at)Queue query stays fastTASK-CHAT-269 surfaces an open-count badge

§11 - Implementation notes

End of TASK-CHAT-267.

Audit

§1 - Verdict summary

TASK-CHAT-267 specifies in-app content reporting for CyberOS chat: 14 normative §1 clauses, one migration introducing chat_reports with four CHECK constraints and a partial unique index, one endpoint, 16 acceptance criteria, 9 Rust integration tests plus 2 client tests, and a 15-row failure-mode inventory. Eight findings were raised across three audit rounds; all eight are resolved in the spec as it stands. The load-bearing clause is §1 #4 (evidence snapshot) - without it the feature is decorative, because the sender can destroy the evidence after the report lands.

§2 - Findings (all resolved)

ISS-001 - The moderation queue would render "(deleted)" for every report that mattered

First draft simply stored target_message_id and let the reviewer read the message back at review time. But chat_messages exposes both edit (edited_at) and soft delete (deleted_at) to the sender. The obvious abuse is therefore: post abuse, wait for the report, delete. The report survives; the evidence does not. Resolved: §1 #4 mandates an immutable snapshot captured at report time (snapshot_body, snapshot_sender_id, and the attachment triple), written once at INSERT and never updated; §3 migration carries the columns; AC #2 and AC #3 assert the snapshot survives a subsequent delete and a subsequent edit respectively.

ISS-002 - The snapshot could race the edit it exists to defeat

Adding a snapshot is not enough if it is read in one statement and inserted in another: a sender editing concurrently can land between the two, and the snapshot captures the sanitised text. Resolved: §6 snapshot_target takes the message row FOR SHARE inside the same transaction that performs the INSERT, holding it for the life of the transaction. §11 explains why FOR SHARE and not FOR UPDATE (we do not write chat_messages, and FOR UPDATE would needlessly serialise unrelated readers). §10 row 1.

ISS-003 - A 409 on a duplicate report is an oracle

First draft returned 409 Conflict when the partial unique index fired. That is a distinguishable response, which means anyone can probe whether a given message already carries an open report simply by reporting it and reading the status code. It also punishes the ordinary user who taps twice because they were not sure it registered. Resolved: §1 #6 mandates 200 OK with the existing report's id, identical body shape to the 201 path; §2 gives both the usability and the oracle rationale; AC #7 asserts 200-not-409 and that only one row exists.

ISS-004 - The rate limit was checked after target resolution, leaking id existence

The first ordering resolved the target (403 for non-member, 404 for unknown id) and then counted recent reports. A caller already over the limit could therefore still use the endpoint as an existence oracle for message ids, because they would receive a 404 rather than a 429. Resolved: §3 handler runs the rate-limit count as the first statement inside the transaction, before snapshot_target; §1 #7; AC #9 asserts that the 21st report against a non-existent message id returns 429, not 404 - the test fails if the ordering regresses. §10 row 6.

ISS-005 - Reported content was being copied into the hash-chained audit log

First draft's chat.report_created payload included the message body, on the reasoning that "the audit row should be self-contained". That is precisely backwards. The audit chain is hash-chained and replicated into the memory module, where it is designed to be durable and resistant to rewriting - the correct property for "who did what when", and the wrong property for a copy of content that someone has just asked us to consider removing. It also doubles the blast radius of that content. Resolved: §1 #8 forbids the snapshot and the free-text detail in the audit payload; §2 explains why; AC #10 asserts the payload carries neither, and asserts the serialised row does not contain the body text anywhere.

ISS-006 - Requiring co-membership would have made the DM case unreportable

Draft §1 #3 required the reporter to share a channel with the target for every target kind. But chat_channels.kind = 'direct' lets any workspace member open a DM with any other, so the single most likely harassment vector - an unsolicited DM from someone you do not work with - would have been the one thing you could not report. Resolved: §1 #3 splits the rule: message and attachment targets require channel membership (you cannot report what you cannot see); subject targets require nothing. AC #5 asserts a subject report succeeds with no shared channel. §2 carries the rationale.

ISS-007 - The partial unique index would never have fired

The index was first written over the three nullable target columns directly. Postgres treats NULL as distinct from NULL inside a unique index, so every row - each with two of the three columns null - would have compared as unique, and the dedup guarantee in §1 #6 would have been silently absent. This is the category of bug that passes every happy-path test. Resolved: §3 coalesces the unused target columns to the nil UUID (00000000-...), which is never a real subject id, matching the module-wide nil-UUID convention; §11 documents why. AC #7's "creates no second row" assertion is what catches a regression.

ISS-008 - ON CONFLICT DO UPDATE would have re-opened the race that ISS-001 closed

An earlier revision used ON CONFLICT ... DO UPDATE ... RETURNING id to get the existing id back in one round trip. That is elegant and wrong: the update would overwrite the original snapshot with a fresh one taken at the time of the duplicate submission - by which point the sender may already have edited the message. The dedup path would have quietly destroyed the evidence the dedup path was protecting. Resolved: §3 uses ON CONFLICT DO NOTHING plus a follow-up SELECT for the existing id; §11 states the reason explicitly so a future reader does not "optimise" it back.

§3 - Resolution

Eight findings, all resolved in the spec. The three that would have shipped a feature that looked correct and was not - ISS-001 (no snapshot), ISS-004 (leaky rate-limit ordering), ISS-007 (dead unique index) - are each pinned by an acceptance criterion that fails loudly on regression, not by a comment.

Score = 10/10.


End of TASK-CHAT-267 audit.