Skip to content

Reports / Dockets

A report (also called a docket or case) is the core unit of DPS work: a community member (the reporter) reports another member (the reported party) for a reason. Reports are created by the Discord bot on behalf of the reporter, worked by assigned agents in the dashboard, and resolved through the status lifecycle below.

The authoritative record is the reports row in PostgreSQL. Related working data (notes, evidence, contact_messages, pending_actions) hangs off the report by foreign key and is removed with it; the timeline and events audit streams survive report deletion by design.

Report identifiers

reports.report_id is a free-form text primary key supplied by the bot at creation (e.g. DPS-CASE-01234 or a UUID-style string). The API bounds and shape-checks it before insert: at most 64 characters, and only [A-Za-z0-9_\-:.#]. This prevents a caller from probing with megabyte keys or wedging arbitrary bytes into the primary key.

Report lifecycle

text
created → Open → Pending → Under Investigation → Validated / Invalidated
   ↑         │                                      │
   │         └─────────── reassign / edit ◄──────────┘
   └────────────────── Closed / Completed / Appealed

The complete status set (lowercased for machine use) is:

open, pending, under investigation, validated, invalidated, closed, completed, appealed.

Display-form titles accepted at creation: Open, Pending, Under Investigation, Validated, Invalidated, Closed, Completed, Appealed.

Creation

POST /reports/create — bot or dashboard with manage_dockets clearance.

Required fields: report_id, reporter, reported, reason, notes, evidence. Optional: reporter_name, reported_name, status, assigned_agent, is_supervisor, thread_id, evidence_url.

The authoritative mutation is rpc_create_report: the report row, the "Report created" timeline row, the canonical report.created event, and the initial evidence row (when evidence is non-empty) all commit in one transaction — a failure can never leave a half-created report that a bot retry then bounces off with 409. A duplicate report_id returns 409 (the pre-check and the unique constraint race are both handled).

Creation accepts form-encoded or JSON bodies. BotGhost's JSON pre-send validator rejects newlines in string values, so form encoding is preferred for bot payloads; the JSON path sanitizes control characters before parsing.

After creation, the bot calls POST /reports/<id>/discord-profiles with the normalized Discord profile snapshots for the reporter and reported party (stored in discord_profiles, keyed by discord_id, upserted — never duplicated). This is a separate endpoint so a profile-capture failure can never prevent report creation.

Updates

PATCH /reports/<id>/update — with manage_dockets (or reassign_docket when assigned_agent is present, which also requires Head Investigator rank or above).

Only whitelisted fields are patchable: reporter, reported, notes, reason, status, assigned_agent, is_supervisor. notes/evidence/timeline stay append-only via their dedicated endpoints, so a PATCH can never wipe history.

Additional guards:

  • Bot-only fields. is_supervisor, notes, and reason are BOT_ONLY_PATCHABLE_FIELDS: the dashboard may never set them (a clearance-2 agent must not be able to rewrite the reporter's original notes or de-escalate a supervisor case by flipping the flag).
  • Assigned-agent gate. Every field update requires the caller to be the assigned agent (or supervisor rank). The bot key bypasses.
  • Status gate. PATCH may only set Open, Pending, or Appealed (manual pre/post-investigation states). Transitions that carry bot work or conclusion side effects (validate/invalidate/close/complete, investigation start) are action-gated and go through /action or /investigation/begin. While an investigation is in progress the only permitted PATCH status is Pending, which is delegated to the shared conclude operation so side effects are identical to /investigation/end.

Each change writes a timeline row ("Assigned agent updated", "Status updated", "Case updated" with detail), bumps updated_at, and records a canonical event (report.assigned / report.status_changed / report.updated) with the pre/post images of the changed fields.

Investigation state

Starting an investigation (action=investigate or POST /reports/<id>/investigation/begin) moves the case to Under Investigation (from Open/Pending). Reopening a concluded case (Closed, Completed, Validated, Invalidated) is allowed only with clearance level 4 or higher and produces an "Investigation reopened" timeline event. See Investigations.

Conclusion

A case is concluded one of three ways:

  1. ValidateUnder InvestigationValidated. Writes "Report validated" and "Investigation concluded" timeline rows, closes any open contact thread, and queues a bot action.
  2. InvalidateUnder InvestigationInvalidated. Same side effects.
  3. ConcludeUnder InvestigationPending, via reports_service.conclude_investigation (reached from /investigation/end or the PATCH status=Pending branch). No bot work, no queue row; timeline + report.investigation_concluded event in one transaction.

Reopening restrictions

  • A concluded case (closed, completed, validated, invalidated) cannot be reopened to Under Investigation by agents below clearance 4.
  • investigate from Open/Pending is always allowed; from a concluded state it is clearance-gated.
  • While Under Investigation, the only permitted status change is the conclude path described above — anything else returns 409.

Deletion

DELETE /reports/<id> — requires delete_docket (clearance 5) and a reason (every caller, including the bot). The authoritative mutation is rpc_delete_report (migration 034):

  • Captures the full pre-image of the report row.
  • Deletes the row; notes, evidence, contact_messages, and pending_actions cascade.
  • timeline and events rows are retained — they are the audit record and deliberately have no foreign key (migration 017 retention model).
  • Writes the forensic admin_write_audit row (which dual-writes the canonical report.deleted event) in the same transaction. If the audit write fails, the deletion rolls back — delete-first-log-after is impossible.

The API surfaces REPORT_NOT_FOUND as 404.

View tracking

POST /reports/<id>/viewed and POST /reports/<id>/evidence/opened are intentional no-ops kept for API compatibility: they used to flood the audit log with "Report viewed" rows and churn list ordering, and nothing consumes them.

  • Legacy list: GET /reports without limit returns every report (optionally ?status=), newest first — used by bot embeds, Analytics, and Agents pages. Supervisor-flagged rows are replaced with a restricted placeholder for non-supervisor callers.
  • Paginated list: GET /reports?limit=…&offset=… runs filter/sort/count/ pagination entirely in SQL via rpc_list_reports (statuses, type all/supervisor/operator, contact_open, ordered sorts newest/oldest/status/agent). Supervisor counts are zeroed for callers who cannot see supervisor reports.

Contact threads

A report can have one agent ↔ reporter conversation (see Appeals & contact):

  • contact_reporter action opens the thread (queues a bot DM, inserts the first message with sender agent).
  • The bot relays reporter replies via /contact/respond.
  • The agent replies via /contact/reply and closes via /contact/close.
  • A second contact_reporter is rejected while a thread is open (CONTACT_ACTIVE) and while a contact action is pending/processing (DUPLICATE_ACTION).
  • Validate/invalidate close any open thread automatically.
  • After close, reply and respond are blocked (409).

Important invariants

  • rpc_report_action is the only authoritative path for validate/invalidate/ investigate/contact_reporter/claim/conclude. Status/assignment, timeline, canonical event, contact message, and queue insert commit or roll back together under the report row lock.
  • The report row is locked (FOR UPDATE) during the action, so two racing actions resolve to exactly one winner; the loser gets a 409 (STATUS_CONFLICT, DUPLICATE_ACTION, CONTACT_ACTIVE, or ALREADY_CLAIMED).
  • The bot API key may create reports and update bot-only fields, but it cannot bypass clearance on dashboard routes that gate it.
  • Deletion without an audit record is impossible (migration 034).
  • Raw user IDs are scrubbed from all serialized text (_scrub_user_ids); Discord profile snapshots are stored data, never live lookups.