Skip to content

RPC Layer

The RPC layer is the set of named PostgreSQL functions in public that own the database's authoritative mutations. They exist because several operations are multi-step and race-prone: a report action touches status + timeline + event

  • queue, a claim must be atomic between two bot ticks, and a deletion must never succeed without its audit record. Doing these in application code with separate table writes would reintroduce partial state, lost updates, and unaudited mutations.

Principle: application code should not recreate authoritative multi-step database operations when an atomic database operation already exists.

Why authoritative database functions exist

  1. Atomicity. Each RPC is one database transaction: either every write commits or none do. A report action can never leave a status change without its timeline/event/queue row.
  2. Race prevention. RPCs lock the rows they guard (SELECT … FOR UPDATE, FOR UPDATE SKIP LOCKED) and re-check preconditions inside the transaction, so two concurrent requests cannot both pass a status guard.
  3. Actor identity. Mutations accept the actor's canonical Discord ID as a dedicated parameter (p_actor_id / p_requester_id), so audit/event rows record identity from an authoritative source — never from display text.
  4. Authorization in depth. RPCs re-validate field whitelists, statuses, ranks, and Director authority even though the API already checked them. A route bug cannot bypass the database.
  5. Audit/event creation in the same transaction. Destructive and high-impact operations write their forensic record inside the mutation's transaction — delete-first-log-after is impossible.
  6. Least privilege. EXECUTE is revoked from public/anon/authenticated and granted only to service_role (migration 016 lockdown), so PostgREST browser roles can never invoke them.

Transaction boundaries and row locking

RPCLockGuard
rpc_report_actionSELECT … FOR UPDATE on the report rowallowed-status set, duplicate contact, already-claimed
rpc_claim_actionFOR UPDATE SKIP LOCKED on pending_actionsoldest pending row for the queue
rpc_discipline_claimFOR UPDATE SKIP LOCKED on discipline_queueoldest available (available_at <= now())
rpc_complete_action / rpc_action_requeueFOR UPDATE on the action rowstatus ∈ pending/processing (or failed for requeue)
rpc_discipline_completeFOR UPDATE on the queue rowstatus ∈ pending/processing
rpc_punishment_revokeFOR UPDATE on the punishment rownot already Revoked/Expired
rpc_delete_report(pre-image select, then delete)exists; audit write in same transaction
rpc_admin_agent_update(row read)field whitelist, rank/clearance/Director/self-edit guards

Which operations are implemented as RPCs

Report domain

RPCOperation
rpc_create_reportReport + timeline + event + optional initial evidence in one transaction
rpc_report_actionThe report action state machine: validate / invalidate / investigate / contact_reporter / claim / conclude — status/assignment + timeline + event + contact message + queue insert
rpc_add_noteNote + timeline + event + updated_at
rpc_add_evidenceEvidence + timeline + event + updated_at
rpc_delete_reportPre-image + delete + forensic audit + report.deleted event
rpc_list_reportsServer-side filter/sort/count/pagination (read)

Contact domain

RPCOperation
rpc_contact_replyAgent message + timeline + event + bot-DM queue insert
rpc_contact_respondReporter message + timeline + event
rpc_close_contact_threadClose state + timeline + event + closing-DM queue insert

Queue domain (report bot work)

RPCOperation
rpc_claim_actionAtomic oldest-action claim (pending → processing) + queue.action_claimed
rpc_requeue_stale_actionsStale-processing recovery (requeue or fail past the cap) + events
rpc_complete_actionLocked terminal completion + queue.action_completed/failed
rpc_action_requeueLocked failed → pending + queue.action_requeued

Punishment / delivery domain

RPCOperation
rpc_punishment_createID generation (collision-retried) + row + audit + punishment.issued + issue delivery enqueue
rpc_punishment_updateWhitelisted edit + before/after audit + punishment.updated
rpc_punishment_revokeLocked revoke + audit + punishment.revoked + revoke delivery
rpc_discipline_enqueueInternal enqueue helper + delivery.queued
rpc_discipline_enqueue_user_lookupProfile-lookup enqueue (one outstanding per user)
rpc_discipline_claimAtomic pickup with live punishment embed + delivery.claimed
rpc_discipline_completeLocked outcome recording (success / paced retry / terminal failure) + delivery events
rpc_discipline_requeue_staleStale-processing recovery + delivery events

Agent / admin domain

RPCOperation
rpc_admin_agent_updateThe single audited agent-record mutation (all guards + before/after audit + agent.updated)
rpc_agent_set_onboarding_fieldsBot lane through the above (narrower whitelist, no rank)
rpc_admin_access_list / grant / revoke / removeAllowlist management (Director-only, audited)
rpc_admin_queue_requeue / cancel / sweepQueue maintenance (audited)
rpc_admin_audit_listPaginated admin audit trail (read)
rpc_admin_report_correctWhitelisted report correction (Director-only, audited)
rpc_admin_schema_status / reloadSchema inventory + PostgREST reload NOTIFY
rpc_admin_health / record / historyHealth metrics + sparse incident history

Event / audit domain

RPCOperation
rpc_write_eventThe single validated event write path (validates type/category/actor_type/metadata)
admin_write_auditForensic audit row + dual-written canonical event (via admin_audit_event_map)
admin_audit_event_mapMaps admin actions to the event taxonomy
rpc_audit_log / rpc_audit_log_v2Server-side search/filter/sort/pagination of timeline

Security model of the RPCs

  • SECURITY INVOKER is the default for new functions (executed with the caller's privileges — service_role's table grants); SECURITY DEFINER is used only where the function must own its writes regardless of caller (the report RPCs), always with a fixed search_path.
  • EXECUTE is revoked from public, anon, authenticated and granted to service_role — enforced globally by migration 016 and restated per function by later migrations.
  • Errors are raised as machine-readable tags (STATUS_CONFLICT, DIRECTOR_REQUIRED, ACTION_ALREADY_COMPLETED, …) that the API maps to HTTP statuses; routes never parse free-form database text for control flow.
  • Field-level whitelists are enforced inside the functions (jsonb_each_text against a constant array), so a client can never slip an unlisted field through.
  • Director/self-edit/hierarchy guards re-derive the requester's live rank from agents inside the RPC (admin_requester_is_director, admin_requester_can_manage_rank), so demotions take effect immediately.

When to add an RPC (and when not to)

Add one when the operation: (a) writes multiple rows that must commit together, (b) has a race window that row locking must close, (c) must produce an audit/event record in the same transaction, or (d) must re-check authority the API also checks (defense in depth).

Don't add one when a simple single-row read/write through the existing API surface suffices, or when the operation is training-runtime state that the API already owns exclusively (training deliberately has no RPCs — the API is the only writer, and introducing RPCs there is not a goal; see migration 023).

WARNING

Project rule (from instructions.md): never introduce an RPC merely to solve a problem the existing API/database architecture already solves. Prefer reusing an existing authoritative mutation over layering a new one.