Skip to content

Punishments

A punishment is a discipline record issued by DPS against a user (identified by Discord user ID). Punishments are browser-session-only records — the bot API key never bypasses clearance on these routes. They are retained records: there is deliberately no DELETE route, and the database blocks DELETE/TRUNCATE at the trigger level.

The punishment record

Authoritative table: public.punishments (migration 029).

Punishment identifiers

punishment_id follows the AA0AA0 format — 2 uppercase letters, 1 digit, 2 uppercase letters, 1 digit (enforced by a CHECK constraint and mirrored in punishment_service.PUNISHMENT_ID_RE). The ID is generated inside the transactional RPC (rpc_punishment_create) with random, non-sequential values and bounded collision retry (8 attempts, then ID_EXHAUSTED). The client never supplies it.

Statuses

StatusMeaning
ActiveCurrently in force
OngoingInvestigation phase
AppealedUnder appeal
RevokedExplicitly withdrawn by DPS (terminal, via the revoke endpoint)
ExpiredNaturally reached its expiration

expires_at is optional; NULL means permanent/non-expiring. There is no automatic expiration scheduler in the current implementation — moving Active → Expired automatically is noted in migration 029 as a later task.

Issued-by identity

issued_by stores the issuing agent's Discord ID, derived server-side from the session — never a client-supplied field. issued_by_name is a display enrichment resolved from the agents table at serialization time. The identity key is issued_by; the display name is snapshot data.

Evidence and notes

evidence and notes are free-text fields on the record (descriptions/links, not files). reason is the required short reason; detailed_reason carries the full context and is editable.

report_id optionally links the punishment to a report. It is deliberately not a foreign key: reports can be hard-deleted by Directors, and a punishment must survive report deletion (same retention rationale as the timeline audit table). The API validates that the referenced report exists at write time.

Permissions

ActionClearanceNotes
View punishments (view_punishments)2List, detail, user profile lookup
Issue / edit / revoke (manage_punishments)4Browser session only
Delivery recovery (requeue failed deliveries)4 or API key/discipline/<id>/requeue

Creating a punishment

POST /punishments (clearance 4, browser session only).

  • Required: user_id (disciplined user's Discord ID), reason.
  • Optional: username, detailed_reason, duration, expires_at, status, evidence, notes, report_id.
  • Server-managed fields may never be supplied by the client: punishment_id, issued_by, issued_at, created_at, updated_at (PROTECTED_PUNISHMENT_FIELDS → 400).
  • rpc_punishment_create validates, generates the ID transactionally, inserts the row, and writes the admin_write_audit row (action punishment.issue, target punishment:<id>) — which in turn dual-writes the canonical punishment.issued event. The issue also enqueues a discipline_queue delivery (action issue) with the full punishment payload so the bot can apply Discord-side enforcement asynchronously.

The dashboard's issue flow first reads the stored Discord profile snapshot for the user (GET /punishments/users/<id>/profile); when none exists, the agent enqueues a user_lookup delivery (POST /discipline/user-lookup) and the bot captures the profile, upserting it into discord_profiles (POST /discipline/user-lookup/<id>/profile).

Editing

PATCH /punishments/<id> (clearance 4, reason required).

Only whitelisted fields are editable (EDITABLE_PUNISHMENT_FIELDS): username, detailed_reason, duration, expires_at, status, evidence, notes, report_id. Everything else — including the short reason (which is the required audit reason in a PATCH and cannot double as the changed field) — is rejected. Setting status = "Revoked" via PATCH is rejected with 409: revocation has its own endpoint. Every edit writes a before/after admin_audit_log row (action punishment.edit) and the canonical punishment.updated event.

Revocation

POST /punishments/<id>/revoke (clearance 4, reason required) — status → exactly Revoked.

  • Already-revoked or naturally expired punishments cannot be revoked again (ALREADY_TERMINAL → 409).
  • The RPC locks the row (FOR UPDATE), captures before/after state, and writes the audit + canonical punishment.revoked event transactionally.
  • A revoke delivery is enqueued for the bot so Discord-side state (e.g. roles) is reconciled.

Appeals

Appeals are represented on the punishment record by the Appealed status. See Appeals for the full lifecycle and who can act on an appeal.

Discord delivery (discipline queue)

Discord-side enforcement is asynchronous and decoupled from the punishment record:

  • public.discipline_queue rows carry the full punishment payload (payload jsonb, migration 031) plus action (issue | revoke | user_lookup), status (pendingprocessingcompleted | failed), attempts, and availability timing.
  • The bot polls GET /discipline/next; rpc_discipline_claim atomically claims the oldest available item (FOR UPDATE SKIP LOCKED) and embeds the live punishment row so enforcement reconciles to current state (retried application of an already-applied transition is a no-op).
  • Outcomes are reported via POST /discipline/<id>/complete with result, note, optional ref (Discord artifact id), and optional retry_after_seconds (transient failure pacing). Terminal failures (3 attempts or explicit failure) are recoverable via /discipline/<id>/requeue.
  • Delivery state is deliberately separate from the punishment status enum: a punishment can be Active while its delivery is pending/processing.

Every queue transition emits a canonical delivery.* event in the same transaction as the queue write.

Retention and audit behavior

  • Punishments are never hard-deleted; the API grants SELECT/INSERT/UPDATE (no DELETE) to service_role, RLS is enabled with zero policies, and a DELETE/TRUNCATE-blocking trigger is the second independent layer.
  • Every create/edit/revoke writes a forensic admin_audit_log row with the acting agent's identity, a reason, and before/after state, and dual-writes the canonical event stream (punishment.issued / punishment.updated / punishment.revoked).
  • The disciplined user's user_id is the identity key; username and user_profile (the stored Discord snapshot) are display data only.