Appearance
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 / AppealedThe 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, andreasonareBOT_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.
PATCHmay only setOpen,Pending, orAppealed(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/actionor/investigation/begin. While an investigation is in progress the only permitted PATCH status isPending, 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:
- Validate —
Under Investigation→Validated. Writes "Report validated" and "Investigation concluded" timeline rows, closes any open contact thread, and queues a bot action. - Invalidate —
Under Investigation→Invalidated. Same side effects. - Conclude —
Under Investigation→Pending, viareports_service.conclude_investigation(reached from/investigation/endor the PATCHstatus=Pendingbranch). No bot work, no queue row; timeline +report.investigation_concludedevent in one transaction.
Reopening restrictions
- A concluded case (
closed,completed,validated,invalidated) cannot be reopened toUnder Investigationby agents below clearance 4. investigatefromOpen/Pendingis 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, andpending_actionscascade. timelineandeventsrows are retained — they are the audit record and deliberately have no foreign key (migration 017 retention model).- Writes the forensic
admin_write_auditrow (which dual-writes the canonicalreport.deletedevent) 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.
List and search
- Legacy list:
GET /reportswithoutlimitreturns every report (optionally?status=), newest first — used by bot embeds, Analytics, and Agents pages. Supervisor-flagged rows are replaced with arestrictedplaceholder for non-supervisor callers. - Paginated list:
GET /reports?limit=…&offset=…runs filter/sort/count/ pagination entirely in SQL viarpc_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_reporteraction opens the thread (queues a bot DM, inserts the first message with senderagent).- The bot relays reporter replies via
/contact/respond. - The agent replies via
/contact/replyand closes via/contact/close. - A second
contact_reporteris 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_actionis 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, orALREADY_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.