Docs › REST API

REST API

Implementation status (shipped): Every UI action is backed by a REST endpoint under the /api/v1 prefix (non-negotiable #1). The surface is a FastAPI app with bearer-token auth (session JWTs + sddi_* API tokens with coarse scopes and per-resource grants), server-side RBAC enforcement, feature-module gating that returns 404 for disabled modules, and an MCP (Model Context Protocol) JSON-RPC endpoint for the Operator Copilot. Interactive Swagger / ReDoc are served from the running API. List endpoints come in two shapes — a bare JSON array (the core IPAM/DNS/DHCP routers) and an {items, total, limit, offset} envelope (the network-modeling routers); both are documented below.

This document covers the cross-cutting API conventions. For authentication providers (LDAP / OIDC / SAML / RADIUS / TACACS+), MFA, sessions and the token model in depth see features/AUTH.md; for the permission grammar and built-in roles see PERMISSIONS.md.


1. Base URL & Versioning

All application endpoints are mounted under a single version prefix:

/api/v1

The version router family is assembled in backend/app/api/v1/router.py and mounted by backend/app/main.py:

app.include_router(api_v1_router, prefix="/api/v1")

A handful of routes live outside /api/v1 because they are infrastructure rather than application surface:

Path Purpose Auth
/health/live Liveness probe — 200 if the process is up none
/health/ready Readiness probe — DB connectivity + schema-at-head + Redis; a pod already ready stays ready on a schema a newer release migrated while every table and column it uses is present (#2777) none
/health/startup Same logic as /health/ready, for slow-start k8s containers none
/health/platform Per-component rollup (db / redis / celery workers / beat) for the dashboard. Anonymous callers get the rollup status only; the component list needs a signed-in caller (#2935) optional
/.well-known/acme-challenge/{token} HTTP-01 ACME challenge (issue #438) none
/metrics Prometheus exposition (when PROMETHEUS_METRICS_ENABLED) bearer: the scrape token (PROMETHEUS_METRICS_TOKEN) or any API token (#1159)

/api/v1 is the only API version. When the surface changes shape in a non-additive way a /api/v2 prefix would be introduced alongside it; today there is exactly one version. New routers are inserted into router.py in alphabetical order so the generated docs list sections A → Z.


2. Interactive Documentation

The FastAPI app serves the standard interactive docs and the raw OpenAPI schema (configured in create_app() in backend/app/main.py and backend/app/api/docs.py):

Route What it serves
/api/docs Swagger UI
/api/redoc ReDoc
/api/openapi.json The OpenAPI 3.x schema document — signed in only

Note the /api/ prefix on the docs routes — they are not under /api/v1. The api serves the Swagger UI and ReDoc bundles itself, from /api/docs/static/, not from a CDN. So both pages work through the web port, under the console’s Content-Security-Policy, and on an air-gapped install. The schema is auto-generated from the Pydantic request / response models on every route, so it always reflects the running build. Each router carries a tags=[...] label (alphabetised in router.py) so the Swagger / ReDoc sidebar groups endpoints by feature area.

Signed in only (#2935). The schema names every route and carries the exact running version in info.version, so /api/openapi.json needs a session or an API token (Authorization: Bearer …) and answers 401 otherwise. The two HTML pages still load for anyone, but they carry only the page title. Each fetches the schema from /api/v1/auth/openapi.json, which accepts the browser’s sign-in cookie and reads it without rotating it: a refresh (POST /api/v1/auth/refresh) would revoke the session the console tab’s token belongs to, and a console refresh racing the docs page’s would log the operator out. Signed in, the docs render as before; “Try it out” carries no token until you use Authorize, as before. Signed out, the page says so and links to the sign-in page. From a script, fetch the schema with an API token:

curl -H "Authorization: Bearer $SPATIUM_TOKEN" https://ddi.example.com/api/openapi.json

The release asset below is unaffected and needs no credential.

2.1 The versioned contract (openapi.json release asset)

/api/openapi.json always describes the running build, which is the right answer for a browser and the wrong one for a client generated ahead of time. Since #903 the same document is attached to every release as openapi.json, so an out-of-repo client — the native app in spatiumnorth/spatiumddi-mobile — can codegen against an exact server version rather than against whatever main happens to be:

https://github.com/spatiumnorth/spatiumddi/releases/download/<tag>/openapi.json

Reproduce the identical file locally at any tag:

make openapi VERSION=2026.08.22-1     # writes ./openapi.json

Both go through scripts/export_openapi.py, which is the only supported way to generate it. Three things that script guarantees and a hand-rolled dump would not:

Version handshake. A client knows which spec it was built against, and GET /api/v1/version (see §7.2) reports what the server is running, to a signed-in caller. Gating a feature is then a version comparison rather than probing for an endpoint and interpreting the 404.

The document declares no servers block. That is deliberate: there is no canonical host, so per OpenAPI 3.1 it means “relative to wherever this is served” and the client supplies the operator’s base URL.

The asset is retained on every release, including ones past the heavy-asset keep window — pinning to an older server is exactly what it is for. scripts/prune-release-assets.sh achieves that through its “unknown / future asset — leave untouched” default branch, so do not add a pattern for openapi.json to the pruner.

2.2 Two shapes the document commits to for code generators

A generated client fails quietly: it compiles, passes review, and is missing whatever the generator could not model. Both rules below exist because a client generated from this document was silently wrong (#907), and both apply to the served document and the release asset alike — a client built against a running server and one built against a pinned tag must not disagree.

Nullable is expressed by absence from required, not by a null union. OpenAPI 3.1 spells an optional value as a union with the JSON Schema null type, and FastAPI emits exactly that. Strict generators that cannot model the null arm skip the member — and skipping a member drops the whole property from the generated type, with a warning rather than an error. So app.openapi() rewrites every such union to the plain schema and takes the property out of required:

What FastAPI emits:

{ "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Feed Url" }

What the document publishes — and feed_url is no longer listed in the schema’s required:

{ "type": "string", "title": "Feed Url" }

A union with more than one non-null arm stays a union, minus the null; a nullable $ref collapses to a bare $ref (keeping any description, dropping the generated title).

Request bodies keep their required list. On the way out required describes what the server sends; on the way in it is what the server enforces, and a field the model declares X | None with no default is a key pydantic demands. So a schema reachable from a requestBody is collapsed but never has a property taken out of required — a client that believed otherwise and omitted the key would get a 422 back. A schema used in both directions (the preview → commit payloads) is resolved the same way, so a model in that position should carry a default rather than rely on the document to paper over it.

Note what this trades: the server still sends "feed_url": null rather than omitting the key, so a strict response validator sees an explicit null against a schema that no longer admits one. That is deliberate — the alternative (dropping nulls from responses) changes the wire for every existing client, and a validator complaint is loud where the generated-code failure is silent. Decoding loses nothing: an absent key and an explicit null both land as nil / undefined.

Timestamps are RFC 3339 with exactly three fractional digits.

2026-05-14T21:59:10.586Z
2026-05-14T21:59:10.000Z     ← a whole second still carries .000

Python’s isoformat() emits six fractional digits, and none at all when the value lands on a whole second. Both are legal RFC 3339 and neither is what most generated decoders accept — Foundation’s ISO8601DateFormatter rejects fractional seconds unless configured for them, and then rejects the values that have none. A fixed shape removes a hand-written workaround from every client. format: date-time on the property is unchanged, so a generator still emits a date decoder rather than a string; only the precision is pinned. See backend/app/core/json_datetime.py.

The rule covers every datetime-typed field the API returns. It does not reach a timestamp parked inside an untyped payload — an Any / dict[str, Any] field such as an evidence trail or an agent telemetry blob, or one a handler formatted into a string itself. Those publish as untyped JSON or bare strings, so no generated decoder points at them and nothing fails to decode; the fix for one of them is to declare the field as a datetime and let it serialise normally.


2.3 Every route declares a response schema

A route with no response_model publishes an unconstrained object, so a generated client gets a dictionary where it should get a model: every field access is stringly-typed and every rename is a silent break. That is the same failure §2.2 describes for nullable properties, arriving through a different door — and it is not fixed by annotating the handler -> dict[str, Any], because FastAPI infers a response model from the return annotation and the inferred one is still {"type": "object"} with no properties.

91 routes were in that state when issue #917 catalogued it. Retyping all of them at once was not the point; stopping the set from growing was, so scripts/lint_untyped_routes.py guards it the way scripts/lint_migrations.py guards destructive migrations:

make lint-untyped-routes            # the check CI runs
make lint-untyped-routes-baseline   # re-record after typing some

The baseline lives at backend/untyped_routes_baseline.txt and may shrink, never grow. A new entry means a route shipped without a schema — declare a response_model instead of baselining it.

Detection runs against the generated OpenAPI document rather than the route attributes, because that is what a client actually consumes. Routes whose handler returns a Response subclass (file downloads, SSE streams) are excluded from that check: they have no JSON body to describe. They have their own obligation instead — see §2.3.1.

2.3.1 A non-JSON response must declare its media type

FastAPI documents a bare -> Response as application/json. A route that then streams a zip, a PDF, a pcap or an SSE stream is publishing a success response it does not produce, so a generated client cannot decode the 200 and a strict validator rejects it — schemathesis reports “Undocumented Content-Type — Received: application/zip, Documented: application/json”.

Set response_class to one of the typed helpers in app/core/responses.py:

from app.core.responses import ZipResponse

@router.post("", response_class=ZipResponse, responses={200: {"description": "The archive"}})
async def download_support_bundle(...) -> Response:
    return Response(content=blob, media_type="application/zip")

response_class, not responses={200: {"content": ...}}. The latter — the shape #861 first used — merges with the inferred application/json rather than replacing it, so the route ends up declaring both. That silences the conformance failure while still telling a generator the endpoint might return JSON, which it never does. It also has to be a subclass that declares media_type: a bare Response or StreamingResponse leaves it None and FastAPI then documents no content at all.

backend/tests/test_response_media_types.py compares each handler’s own media_type= against the generated document and fails a route that serves something it did not declare or that declares application/json alongside a non-JSON body. Fourteen routes were in the first state when #921 was filed — three export.pdf routes had been fixed one at a time in #861, and the sweep found eleven more; all seventeen now use response_class.

2.4 Copilot tools and REST routes are two views of one capability

Non-negotiable #13 requires every new REST surface to get matching MCP tools. The converse is equally required, and was not enforced until #917: a capability reachable only from the Operator Copilot is invisible to every external client, because the copilot tools are written against the service layer rather than against HTTP.

Five issues from the mobile client turned out to be instances of this (#903, #906, #907, #913, #914), and a sweep of every registered tool against the route table found four more: fleet-wide lease search, IPAM hygiene findings, the vendor rollup, and the customer decommission summary. (Pool occupancy was the same shape and shipped earlier as #913.)

When adding a tool that computes something, add the route too, and have both call one shared service function so they cannot answer the same question differently.

3. Authentication

The API authenticates via the HTTP Authorization: Bearer <token> header. Two credential kinds are accepted on the same header, resolved in backend/app/api/deps.py (get_current_user):

  1. Session JWT — a short-lived access token issued by POST /api/v1/auth/login.
  2. API token — a long-lived sddi_* token issued by POST /api/v1/api-tokens, for scripts and machine clients.

Tokens that start with the sddi_ prefix are routed straight to the API-token validator; everything else is JWT-decoded. A missing or invalid credential returns 401; an authenticated-but-disabled user returns 403.

3.1 Login

POST /api/v1/auth/login

{ "username": "admin", "password": "admin" }

The refresh token is never in a response body. On success the server sets it as the HttpOnly spatium_refresh cookie (Set-Cookie), and the same holds for the response of POST /api/v1/auth/login/mfa. On success (and no MFA) the body is:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "force_password_change": true,
  "mfa_required": false,
  "mfa_token": null
}

When the local user has TOTP enabled, login instead returns a challenge with no tokens, and the caller must complete the second factor at POST /api/v1/auth/login/mfa:

{
  "access_token": null,
  "token_type": "bearer",
  "force_password_change": false,
  "mfa_required": true,
  "mfa_token": "eyJ...type=mfa...(5-minute TTL)"
}

The access token’s default lifetime is 15 minutes (ACCESS_TOKEN_EXPIRE_MINUTES); the refresh token’s is 7 days (REFRESH_TOKEN_EXPIRE_DAYS). The default credentials on a fresh install are admin / admin with force_password_change=true — the API bars a forced-change account from every endpoint except the password-recovery allowlist (/auth/change-password, /auth/logout, /auth/me, /auth/password-policy) until the password is rotated.

3.2 Refresh & rotation

POST /api/v1/auth/refresh

takes no body: the refresh token is read from the spatium_refresh cookie the login set. Without the cookie the answer is 401 “Missing refresh token”. It returns a fresh { access_token, token_type, force_password_change } and sets the rotated refresh token as the cookie again. Refresh is rotating: the presented refresh token’s session is revoked and a new session (and new refresh token) is issued. Each access token carries the issuing session’s UUID as its jti claim, so revoking the session (POST /api/v1/auth/logout or a superadmin force-logout via the sessions surface) invalidates every access token minted from it on the next request.

3.3 API tokens

API tokens are minted at POST /api/v1/api-tokens. The raw token is returned exactly once in the create response (the DB stores only a sha256 hash plus a short display prefix). They authenticate on the same Authorization: Bearer header as session JWTs.

Two narrowing mechanisms apply, both enforced in deps.py before RBAC:

A token can never exceed its owner’s RBAC: the scope / grant check is an additional gate, applied on top of the owning user’s permissions.

See features/AUTH.md for the full auth provider, MFA, and session model.


4. Authorization (RBAC)

Authorization is enforced server-side, independently of the UI (non-negotiable #3). Permissions are {action, resource_type, resource_id?} triples with wildcard support; the helpers (require_permission / require_any_permission / require_resource_permission and friends) live in backend/app/core/permissions.py. Most routers apply the gate at the router-include level — e.g. the IPAM router carries a require_any_resource_or_scoped(...) dependency over all of its IPAM resource types — so every handler under the prefix is gated uniformly. Superadmins (the legacy User.is_superadmin column or a group → {*, *} wildcard role grant) bypass these checks.

A caller can introspect its own effective grants at GET /api/v1/auth/me/permissions. The full grammar, built-in roles (Superadmin / Viewer / IPAM-DNS-DHCP Editors / Auditor / Compliance Editor / Change Approver / …), and wildcard semantics are in PERMISSIONS.md.


5. Feature-Module Gating

Top-level resource families can be turned off as feature modules (non-negotiable #14). A disabled module’s router is gated with Depends(require_module("...")) and returns 404 Not Found — not 403 — so the API surface mirrors an air-gapped deployment where the feature simply isn’t installed (backend/app/services/feature_modules.py):

{ "detail": "Feature 'network.circuit' is disabled." }

Disabling a module also stops its scheduled work, not just its routes (#2279): every module-owned Celery task carries @module_gated("<module id>") (backend/app/tasks/module_gate.py) and does nothing while the module, or a parent in its requires= chain, is off — no SNMP polls, TLS probes, conformity runs, RDAP / RPKI / RIPEstat fetches, or nmap auto-profile of new DHCP clients. Core, retention and prune tasks run regardless; each is listed with its reason in UNGATED_BEAT_TASKS, and tests/test_module_gated_tasks_2279.py fails any beat task that is in neither place.

A module’s shipped default lives in default_enabled on its catalog entry and nowhere else — a feature_module row means an operator changed it. A module ships enabled only if it is core IPAM / DNS / DHCP workflow, a zero-footprint UI convenience, or a hand-invoked read-only diagnostic; everything else ships disabled (#1069). 16 of 57 are on out of the box. Settings → Features lists every module with its description either way, so a disabled one is still discoverable. Examples of gated prefixes (from router.py):

Prefix Module id
/api/v1/ai ai.copilot
/api/v1/circuits network.circuit
/api/v1/cloud integrations.cloud
/api/v1/conformity compliance.conformity
/api/v1/kubernetes integrations.kubernetes
/api/v1/nmap tools.nmap
/api/v1/saved-views ui.saved_views

Operators toggle modules via the admin feature-modules surface under /api/v1/admin.


6. Pagination

There are three list shapes in the codebase: the {items, total, limit, offset} envelope (6.1), bare arrays (6.2), and Page[T] ({items, total, page, page_size}, backend/app/api/pagination.py), which DNS records and DHCP leases answer and which that module names as the shape for new endpoints that can grow unbounded. The audit list answers {total, items}. Check the route’s schema in /api/docs rather than assuming one shape.

6.1 {items, total, limit, offset} envelope

The network-modeling and newer routers (circuits, services, ASNs, VRFs, overlays, multicast, TLS certs, …) accept limit + offset query params and return a typed envelope. From backend/app/api/v1/circuits/router.py:

GET /api/v1/circuits?limit=100&offset=0
{
  "items": [ /* … CircuitRead objects … */ ],
  "total": 312,
  "limit": 100,
  "offset": 0
}

limit is validated per-endpoint (commonly ge=1, le=500, with a few routers allowing higher ceilings); offset is ge=0. total is the unpaginated row count so clients can compute page counts. These endpoints also expose resource-specific filter params (e.g. provider_id, status, search, tag) alongside the page controls.

6.2 Bare array (core IPAM / DNS / DHCP)

The core IPAM, DNS, and DHCP list endpoints return a plain JSON array of the resource. For example GET /api/v1/ipam/spaces is declared with response_model=list[IPSpaceResponse] and returns:

[
  { "id": "…", "name": "Corporate", "is_default": true, "…": "…" },
  { "id": "…", "name": "DMZ", "is_default": false, "…": "…" }
]

These predate the envelope convention; the client filters/sorts these in the browser or via query params on the specific endpoint.

Exception — address listing (#517, 2026.07.04-1). GET /api/v1/ipam/subnets/{id}/addresses stays backward-compatible (still a bare list[IPAddressResponse]) but now accepts optional q / hostname / mac / sort / order / limit / offset, and sets an X-Total-Count response header (CORS-exposed) when the result is windowed. For cross-subnet address queries use the envelope endpoints GET /api/v1/ipam/addresses/search (paginated, joined subnet/space context) and GET /api/v1/ipam/addresses/search/ids (a capped id list for select-all-matches). Both are permission-scoped in SQL.


7. Request / Response Examples

7.1 Create an IP space

POST /api/v1/ipam/spaces

Request body (IPSpaceCreate — only name is required; everything else has a default):

{
  "name": "Corporate",
  "description": "Primary internal routing domain",
  "is_default": true,
  "color": null,
  "tags": { "env": "prod" }
}

Response — 201 Created, IPSpaceResponse:

{
  "id": "4f6c2a9e-3b1d-4e7a-9c2f-1a2b3c4d5e6f",
  "name": "Corporate",
  "description": "Primary internal routing domain",
  "is_default": true,
  "tags": { "env": "prod" },
  "color": null,
  "dns_group_ids": [],
  "dns_zone_id": null,
  "dns_additional_zone_ids": [],
  "ddns_enabled": false,
  "ddns_hostname_policy": "client_or_generated",
  "created_at": "2026-06-27T12:00:00.000Z",
  "modified_at": "2026-06-27T12:00:00.000Z"
}

Creating a space whose name already exists returns 409 Conflict with {"detail": "An IP space named 'Corporate' already exists"}. Every mutation is written to the append-only audit_log before the response returns (non-negotiable #4).

Read an entry’s recorded details with GET /api/v1/audit/{id}, which returns old_value, new_value, changed_fields and error_detail (for example a scheduled upgrade’s direction and schema_check). GET /api/v1/audit leaves them null unless called with include_details=true, since a page can be 500 rows. Values are redacted on read: a key that looks like a credential (password, token, secret, *_key, *_encrypted, …) comes back as [redacted], and secret-shaped strings (JWTs, Fernet tokens, bearer values, chat-webhook URLs, the signing parameters of a presigned S3 / GCS / Azure SAS URL, a storage connection string’s AccountKey) are masked wherever they appear; a string carrying a PEM private key is replaced whole. A boolean or integer under a credential-shaped key is kept, so a writer’s smtp_password_set: true still says whether the secret was set. Both endpoints need read on audit_log.

7.2 Read the running version (public, details signed in)

GET /api/v1/version answers everyone, but version and stack details are for signed-in users only (#2935). An anonymous caller gets a coarse handshake: enough to know it reached a SpatiumDDI control plane, and nothing about which release. restricted: true says the other fields were withheld rather than empty. version carries the placeholder "restricted" rather than null, so an installer ISO built before this change, which checks only for a non-empty version string, still reports the control plane as reachable; it is not a release, and SpatiumDDI’s own version parsers read it as an unknown version:

{
  "product": "SpatiumDDI",
  "api_level": 1,
  "restricted": true,
  "version": "restricted",
  "latest_version": null,
  "update_available": false,
  "latest_release_url": null,
  "latest_checked_at": null,
  "release_check_enabled": false,
  "latest_check_error": null,
  "appliance_mode": false,
  "appliance_version": null,
  "appliance_hostname": null
}

api_level is the major version of the REST surface (the /api/v1 prefix), and changes only when a v1 client can no longer talk to the server. The appliance installer uses product to check that the URL it was given is a control plane before it wipes a disk.

A signed-in caller (a session or an API token) gets the full answer:

{
  "product": "SpatiumDDI",
  "api_level": 1,
  "restricted": false,
  "version": "2026.06.25-1",
  "latest_version": null,
  "update_available": false,
  "latest_release_url": null,
  "latest_checked_at": null,
  "release_check_enabled": true,
  "latest_check_error": null,
  "appliance_mode": false,
  "appliance_version": null,
  "appliance_hostname": null
}

8. Errors

Application errors use FastAPI’s standard error envelope: a JSON object with a detail field and the appropriate HTTP status code.

{ "detail": "Invalid credentials" }

detail is usually a human-readable string, but some handlers return a structured object — e.g. a password-policy failure returns {"detail": {"reason": "password_policy", "errors": [...]}}, and an IP collision returns {"detail": {"warnings": [...], "requires_confirmation": true}}.

Common status codes across the surface:

Status Meaning
400 Bad Request Semantically invalid input (e.g. wrong current password)
401 Unauthorized Missing / invalid / expired credential, or insufficient token scope
403 Forbidden Authenticated but not permitted (RBAC denial, superadmin-only, disabled account, forced password change)
404 Not Found Resource doesn’t exist or its feature module is disabled
409 Conflict Uniqueness / overlap / collision conflict, or a delete refused because rows still reference the target
413 Content Too Large The request body is over the API’s body cap (see below)
422 Unprocessable Entity Pydantic request-body / query-param validation failure, or a request-supplied reference to a row that does not exist
429 Too Many Requests A rate limit or attempt budget tripped: the per-IP, per-username or install-wide sign-in ceilings, the MFA wrong-code and step-up budgets, appliance registration, and a few others (some carry Retry-After)
503 Service Unavailable Maintenance mode, transient DB-connection-closed (carries Retry-After), or a failing readiness check

Request body limits

Every request body is capped (#2780). The api answers 413 to a body over the cap. A declared Content-Length over it is refused before any of the body is read; a body sent without one is counted as it arrives and refused once it passes the cap. The cap is MAX_REQUEST_BODY_BYTES (default 8 MiB, minimum 64 KiB), set in the api’s environment: .env on Docker Compose, api.env in the Helm chart. It applies to the api port itself. The bundled nginx admits 1 MiB on /api/, except on the routes below, which each have an nginx location that passes the body through to the api unbuffered. For the importers, the zone spreadsheet, the pcap upload and the agents’ ingested-records push, that location’s limit is exactly the route’s own limit in the table below, and nginx answers a body over it with the same JSON 413 the api sends, before any of it is proxied (#3036). Backups (2 GiB) and upgrade images (4 GiB) pass through at those sizes.

Routes that take larger bodies keep their own limits, which the cap never lowers:

Route Limit
POST /appliance/upgrade-images 4 GiB image
POST /backup/… 2 GiB archive
POST /dns/import/… 50 MiB (BIND9 archive, and its preview sent back to commit)
POST /dhcp/import/…, POST /ipam/import/… 25 MiB
POST /dns/groups/{id}/zones/{id}/import/spreadsheet/{preview,commit} 10 MiB
POST /appliance/supervisor/pcap/upload/{id} 116 MiB capture
POST /dns/agents/ingested-records 64 MiB (one dynamic zone per request)

The upload routes (the importers, the zone spreadsheet import, the branding logo, upgrade images and backups) check the login before reading the upload: a request without a valid login, or without the permission, is answered 401 / 403 before any of the file is read. The HELD endpoints read at most their 64 KiB document limit.

A 422 validation error carries FastAPI’s structured field-error list:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "name"],
      "msg": "Field required"
    }
  ]
}

Validation, permission, and auth errors are raised as typed HTTPExceptions and turned into clean 4xx responses by FastAPI’s own machinery. Anything that slips past every handler is caught by a last-resort exception handler in main.py that records the failure for the diagnostics surface and returns a generic 500 {"detail": "Internal Server Error"} (it never echoes the exception text). Transient DB-connection-closed errors (e.g. during a backup restore) are converted to a 503 with Retry-After: 1 so agent long-polls back off rather than cascading.

Database integrity errors

Three SQLSTATEs are mapped to client errors before that last-resort handler sees them (app/core/integrity_errors.py, wired in main.py):

SQLSTATE Condition Answer
23505 Unique violation 409 — the data conflicts
23503 Foreign key, referent missing, value came from this request 422 — "<column> references a <table> row that does not exist."
23503 Foreign key, row still referenced, value came from this request 409
class 22 Value Postgres refuses as data (over-length, NUL byte, malformed literal) 422

Everything else re-raises to the 500 path deliberately. NOT NULL (23502) and CHECK (23514) violations mean the server built a bad row, and answering 4xx would both misattribute the fault and hide it — a 4xx is invisible to the conformance fuzz’s no-5xx assertion, which is the only thing watching for that class.

The “value came from this request” qualifier is what splits the foreign-key arm (#922). Postgres names the offending column and value in the error’s DETAIL; the value is compared against every scalar in the request body, path params and query string, and the request is only blamed when every offending value is one it actually carried. A dangling reference the server computed — including one half of a composite key — still answers 500, on purpose.

If you are adding a similar mapping, note that IntegrityError.orig is SQLAlchemy’s AsyncAdapt_asyncpg_dbapi wrapper: it re-exports sqlstate but not detail. The asyncpg error carrying the DETAIL line hangs off its __cause__, so reading orig.detail returns "" for every error and the handler silently does nothing.

Request correlation

Every request gets an X-Request-ID (read from the inbound header or minted as a UUID) bound into the structured logs and echoed back on the response header, so a client can correlate a response with the server-side log lines for that request (non-negotiable #7).


9. CORS, Trusted Hosts, Maintenance Mode


10. MCP (Operator Copilot) Endpoint

The Operator Copilot exposes a Model Context Protocol server over JSON-RPC 2.0 (the MCP spec’s “Streamable HTTP” transport) at:

/api/v1/ai/mcp

It is part of the /api/v1/ai router, gated by the ai.copilot feature module — disable the module and the endpoint 404s. It shares the same auth surface as the rest of the API: a session JWT for browser clients, or an API token for external MCP clients (Claude Desktop / Cursor / any MCP-speaking client). External clients use a token with the read scope, which is explicitly allowed to POST the JSON-RPC frame even though it is a write method.

A bare GET /api/v1/ai/mcp returns server identity (handy for a sanity check; auth still required):

{
  "server": { "name": "spatiumddi", "version": "1.0.0" },
  "protocol_version": "2025-06-18",
  "available_tools": 0,
  "transport": "streamable_http"
}

POST /api/v1/ai/mcp handles one JSON-RPC request (or a batch). Supported methods are initialize, notifications/initialized, tools/list, tools/call, and ping; resources/*, prompts/*, sampling/*, and completion/* return method-not-found. A tools/list call returns the registry’s read-only tools — the hundreds of find_* / count_* reads, deliberately excluding propose_* write tools so the MCP surface never even stages a mutation (writes go through the C2-gated /api/v1/ai/proposals apply flow in the chat UI instead). Per-tool default-enabled state and feature-module filtering both apply, so the advertised set reflects what the operator has actually turned on.


See also