Appearance
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
| Status | Meaning |
|---|---|
Active | Currently in force |
Ongoing | Investigation phase |
Appealed | Under appeal |
Revoked | Explicitly withdrawn by DPS (terminal, via the revoke endpoint) |
Expired | Naturally 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 link
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
| Action | Clearance | Notes |
|---|---|---|
View punishments (view_punishments) | 2 | List, detail, user profile lookup |
Issue / edit / revoke (manage_punishments) | 4 | Browser 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_createvalidates, generates the ID transactionally, inserts the row, and writes theadmin_write_auditrow (actionpunishment.issue, targetpunishment:<id>) — which in turn dual-writes the canonicalpunishment.issuedevent. The issue also enqueues adiscipline_queuedelivery (actionissue) 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 + canonicalpunishment.revokedevent transactionally. - A
revokedelivery 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_queuerows carry the full punishment payload (payloadjsonb, migration 031) plus action (issue|revoke|user_lookup), status (pending→processing→completed|failed), attempts, and availability timing.- The bot polls
GET /discipline/next;rpc_discipline_claimatomically 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>/completewithresult,note, optionalref(Discord artifact id), and optionalretry_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
Activewhile its delivery ispending/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_logrow 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_idis the identity key;usernameanduser_profile(the stored Discord snapshot) are display data only.