Appearance
Adding an API Feature
The expected path for a new feature that starts in the dashboard and ends in the database:
text
Frontend
↓ typed wrapper + page state
API Route
↓ parse, validate, gate
Service
↓ domain rules, shared operation
Database Access / RPC
↓ authoritative mutation, one transaction
Audit / Events
↓ canonical record in the same transaction
ResponseWork through the layers in that order, but decide the data contract first: what the mutation must do atomically, what identity/authorization it needs, and what its audit record is. That decision determines where the logic lives.
Layer by layer
Frontend
- Add a typed wrapper in
src/lib/api/<domain>.ts(method, path, params, response type intypes.ts). - Call it from the page; on success update from the response and/or re-fetch; on
ApiErrorsurface the message and re-fetch on conflicts. - Sanitize any URL values before rendering; gate controls by the existing permission flags.
API route (app.py)
Keep the handler a thin adapter:
- Parse and validate input (required fields, enums, URLs, bounds — see Input validation).
- Authenticate (
verify_api_keyoractive_agent_from_session) and authorize (authorize_dashboard,require_admin, or a training gate). - Perform row-level checks (
supervisor_report_denied,is_assigned_agent). - Delegate to the service operation.
- Map RPC error tags to HTTP responses; return the serialized payload.
Do not put business rules in the route. If the operation is multi-step or race-prone, do not implement it with several table writes in the route — see the RPC step.
Service
- Put the domain operation in the owning service module (
reports_service,punishment_service, …) or a new service if none owns it. - The service owns the pre-flight rules and the call to the authoritative mutation; routes stay thin.
- Reuse existing shared operations instead of duplicating their logic — e.g. do not add a second path for a report action when
perform_report_actionexists.
Database access / RPC
- For a simple single-row read/write that is not race-prone and needs no audit, direct table access through
db_accesshelpers is fine. - For a multi-write, race-prone, or audited operation, add a named RPC in a new migration:
- one transaction for all writes;
SELECT … FOR UPDATE(orSKIP LOCKED) on the contended row;- in-function re-validation of whitelists/statuses;
revokefrompublic, anon, authenticated+grant execute to service_role(migration 016 convention);- machine-readable error tags, not free-form text.
- Do not introduce an RPC when the existing API/database architecture already solves the problem (project rule). Prefer reusing an existing authoritative mutation.
Audit / events
- If the mutation's integrity requires a record, write it inside the same transaction (via
rpc_write_eventoradmin_write_auditfrom the RPC). - Application-level telemetry that is not integrity-critical may use
event_service.record_event(best-effort by design). - Record canonical
actor_id(Discord ID from the session) and a displayactor_name; never encode identity inside display text (migration 034 invariant). - Add the new event type to
event_service.EVENT_TYPESand the Event types reference.
Decision guide
| Question | Answer → layer |
|---|---|
| Does the operation write multiple rows that must commit together? | RPC |
| Can two requests race on the same row? | RPC with a row lock |
| Must the mutation produce an audit/event record? | RPC (same transaction) or event_service (best-effort) |
| Is it a simple read or single-row update with no race? | db_access helpers in the route/service |
| Does a protected operation already exist for this? | Use it — do not duplicate |
Verification
- API contract suite: add/extend
*_tests.pyfor the route (request parsing, gates, RPC error mapping, response shape) — run with the repo's Python environment. - SQL: apply the migration to a scratch project; exercise happy path, error tags, and privileges.
- Frontend:
npm run build(typecheck), manual demo-environment pass. - Update the API reference, RPC reference, schema, and the Changelog with the change.