FastAPI project layout
_shared/fastapi-project-layout
| Kind | shared |
|---|---|
| Applies to | backend |
| State | written |
| Maturity | prod |
| Reusable | extract |
| Stack | python-3.11, fastapi-0.118, starlette-0.48, pydantic-2.12, pydantic-settings-2.11, sqlalchemy-sync, postgres-15, alembic-1.16, uvicorn-0.37, redis-6 |
| Depends on | nothing: a root foundation |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
One Python process answers every HTTP and WebSocket request of a product, and one file says what that process is made of: which routers exist, in which order the middleware wraps them, which exception becomes which envelope, and which front door each router opens. The invariant that makes it trustworthy is that a route without a surface decision cannot exist: mount_router refuses to register a router without surfaces=, and assert_every_route_gated(app) refuses to boot the process if a route slipped past it, so "someone forgot" is a failed deploy and never an open endpoint.
This foundation fixes the folder shape (api/routers, services, models, schemas, core, middleware, migrations, tests), the composition order in the entry point, the settings class and its boot-time refusals, and the rule that a store, a transport or a key source is selected by name and never inferred from which credential happens to be present (ADR-0019). Every backend recipe in this kit assumes this shape and says only what it adds.
When to use
Include it from every kind: backend recipe. Read it before adding a router, before adding a settings field, before writing a new service module, and before answering "where does this code go". Trigger phrases: "new endpoint", "add a router", "which env var", "where do I put the service", "the app will not boot", "why is this route 403 for a Prima session", "can I just app.include_router".
Do not use it for the auth dependency itself (_shared/auth-dependency-pattern, S5), for how a tenant route derives its workspace (_shared/workspace-scoping, S6), for the error envelopes (_shared/error-envelope, S7), for migrations (_shared/alembic-migrations, S8), for the test tree (_shared/test-layout, S9), for background jobs (_shared/scheduler-and-background-work, S12) or for the container and estate deploy (_shared/cloudflare-worker-and-estate-deploy, S13). It is the frame those fill in. A Cloudflare Worker (an Astro client site) is not a FastAPI service and does not follow this recipe.
Stack assumptions
Pinned as measured in alchemy_labs/backend/requirements.txt at fd8558f (2026-09-11): fastapi==0.118.2, starlette==0.48.0, uvicorn==0.37.0, pydantic==2.12.0, pydantic-settings==2.11.0, alembic==1.16.5, psycopg2-binary==2.9.10, redis==6.4.0, cryptography==46.0.0, bcrypt==5.0.0, httpx==0.28.1, python-dotenv==1.1.1, sentry-sdk[fastapi]==1.40.0. python-jose==3.5.0 is still installed for its JWTError type only (S5). Python 3.11 in the container (Dockerfile FROM python:3.11-slim, lines 23 and 38); pytest.ini line 13 also says minversion = 3.11, but that key is pytest's minimum version, not Python's, and the render harness in this repo runs the same tree on 3.13.7 (2026-09-11). Postgres 15 with SQLAlchemy in synchronous mode: one engine, poolclass=QueuePool, pool_size=2, max_overflow=3, pool_recycle=300, pool_pre_ping from settings (core/database.py lines 50 to 58, commit b71e1ba, 2026-06-10); the session factory is expire_on_commit=False, autocommit=False, autoflush=False (lines 61 to 66).
Foundations assumed by every recipe that includes this one: S5 (the dependency vocabulary), S6 (WorkspaceID), S7 (envelopes), S8 (a migration per schema change, verify_schema_at_head() at boot), S9 (tests/ on the :5434 test Postgres).
Environment names this layer reads, and what happens when they are wrong (all in core/config.py, class Settings(BaseSettings), lines 16 to 24: .env next to backend/, case-insensitive, extra="ignore"):
| Name | Default | Refusal |
|---|---|---|
ENVIRONMENT | development | pattern ^(development|staging|production)$ (line 30); anything else fails settings construction |
JWT_SIGNING_KEY_SOURCE | ephemeral | pattern ^(env|file|ephemeral)$ (87 to 91); ephemeral raises in staging and production (validator 241 to 259, and again in core/jwt_keys.py 263 to 270) |
JWT_ISSUER | the Labs origin | none at boot; Prima compares it byte for byte |
EMAIL_TRANSPORT | smtp | unknown value raises (493 to 508); smtp declared without credentials raises, smtp defaulted without credentials raises in production and falls back to resend with a logged warning elsewhere (510 to 565) |
LLM_PROVIDER | infomaniak | unknown value raises in services/llm/__init__.py::get_provider (line 51 reads it, 73 raises) at first use, not at boot |
PRIMA_LLM_PROVIDER | infomaniak | read by services/mistral/client.py (248 to 263), not by get_provider; unknown value raises there at first use |
SECRET_KEY, JWT_SECRET_KEY, JWT_REFRESH_SECRET_KEY | dev literals | the dev literal or fewer than 32 characters raises in production (261 to 289); the two JWT ones sign nothing any more and are kept because a pre-deploy script asserts them (60 to 68) |
DB_PASSWORD | a dev literal | the dev literal raises in production (232 to 239) |
ALLOWED_HOSTS | empty | required in production (207 to 215); feeds TrustedHostMiddleware (app_fastapi.py 510 to 514). The container healthcheck and the Argus probe reach the process by loopback, so the list carries localhost (and the container hostname) or the probe sends the public Host header; see Deploy & ops |
ALLOWED_ORIGINS | list | the CORS allow-list; every surface origin from core/surfaces.py::all_surface_origins() is appended at import (app_fastapi.py 399 to 411). This, ALLOWED_HOSTS and PRIMA_EXTRA_ORIGINS are typed Union[str, List[str]] (lines 151, 189, 205) and read as a comma-separated string: a field_validator(mode="before") splits on commas (1018 to 1053). The Union is load-bearing, not a loose annotation: pydantic-settings treats a field whose type is a union with a complex member as one whose JSON parse may fail and hands the raw string on to the validator, while a field typed plainly list[str] is json.loads-ed by the env source before any validator runs, and a comma-separated value then raises SettingsError: error parsing value for field "ALLOWED_ORIGINS" from source "EnvSettingsSource" (measured 2026-09-12 with pydantic-settings 2.11.0 under all three typings). A new service copies the validator together with either the Union typing or Annotated[list[str], NoDecode] (from pydantic_settings import NoDecode); with a bare list[str] the validator is dead code |
ENABLE_DOCS | bool | /api/docs, /api/redoc, /api/openapi.json exist only when true (387 to 395) |
REDIS_ENABLED, REDIS_URL | on (REDIS_ENABLED: bool = Field(default=True), line 388; turned off by tests/conftest.py line 36 and by tests/test_prima_ed25519_interop.py line 54 with setdefault) | a Redis init failure raises only in production (lifespan 301 to 311); REDIS_URL is assembled by a model_validator(mode='after') because a field validator would see the defaults (417 to 435) |
three feature flags of the form <FEATURE>_ENABLED (SPARK_ENABLED, AIRIEL_ENABLED, AURUM_ENABLED) | off | the flagged routers are not mounted at all when off (986 to 1041); a fourth flag, INFOMANIAK_OIDC_ENABLED (core/config.py 160), does not follow the pattern: auth_infomaniak_router is mounted on ALL_SURFACES regardless (821) and its endpoints answer 503 until the flag is on (Deviations) |
ENABLE_METRICS | off | /metrics via prometheus_fastapi_instrumentator (1213 to 1224) |
SENTRY_DSN | unset | Sentry is initialised before any other import, send_default_pii=False, health transactions dropped (10 to 31) |
ENABLE_FILE_LOGGING | off | stdout always; a file handler is opt-in (146 to 163) |
The fail direction of a settings error is closed: settings = Settings() runs at import of core.config (line 1058), so a bad value stops the process before a socket is opened.
Data model
No table belongs to this layer. Its data is the folder tree and the import graph:
backend/
app_fastapi.py the composition root: imports, logging, lifespan, app, CORS, middleware,
exception handlers, static mount, 93 mount_router calls, boot assertion
api/
routers/ 90 modules whose name ends in _router.py plus public_routes.py (declared,
not mounted at fd8558f, see Gotchas); a router
declares its own prefix or takes one at mount (two modules hold two
APIRouters, see Gotchas)
forms/ four router modules; two are mounted (redirect_ai_router,
form_editor_ai_router), two are exported and mounted nowhere
(template_router, submission_router)
surface_gate.py SurfaceGate, SurfaceExempt, mount_router, assert_every_route_gated
dependencies.py get_db, get_current_user and the Annotated aliases (S5), WorkspaceID (S6)
dependencies/ a package that shadows dependencies.py and re-exports it; rate_limit.py,
surface.py (see Gotchas)
middleware/ SecurityHeadersMiddleware, UnifiedMiddleware (the only two mounted from here)
middleware/ ErrorLoggingMiddleware, WorkspaceMiddleware
core/
config.py Settings, the validators, the module-level settings instance
database.py engine, SessionLocal, get_session, get_db_context, verify_schema_at_head, init_db
surfaces.py the surface registry (S5)
jwt_ed25519.py the signing primitive; jwt_keys.py, the key set and its source (S5)
exceptions.py AlchemyException and its subclasses, one per HTTP outcome
cache.py, tracing.py, session_cookies.py, enums.py
models/ 57 SQLAlchemy modules; models/__init__.py imports 36 of them (see below)
schemas/ 43 Pydantic request and response modules and six .sql files;
error_schemas.py owns the envelope
services/ 111 entries: a module or package per domain; one of them carries its own
router (services/primalayered); services/primaapi holds no router, its
router is api/routers/primaapi_router.py
migrations/ the Alembic chain (S8); alembic.ini beside app_fastapi.py
tests/ pytest, tests/conftest.py points at the :5434 Postgres (S9)
static/ the embed scripts served at /static
scripts/ operator scripts; backend/start.sh picks the worker count
Counts measured 2026-09-11 at fd8558f: module counts are ls <folder>/*.py | grep -v __init__ | wc -l; the services/ figure is ls services | wc -l and counts entries, packages included.
models/__init__.py is load-bearing, not cosmetic: it imports the model classes (lines 7 to 105) so Base.metadata knows their tables before create_all() runs under pytest and before any relationship is resolved. Its own comments record three tables that were missing from it and therefore missing from the test schema (user_surface_grants, scheduled_messages, the service desk tables; lines 80 to 103). At fd8558f it is still incomplete: 36 of the 57 model modules are imported and 21 are not (the list is in Gotchas). After import models alone, Base.metadata holds 152 tables and outreach_links is not one of them (measured 2026-09-11). Under pytest the schema is not built from that state: tests/conftest.py::test_engine runs import app_fastapi (line 97) before Base.metadata.create_all() (line 103), its comment at 85 to 96 says why, and the routers pull in 17 of the 21 transitively (api/routers/outreach_router.py line 16 imports models.outreach_model), so after the app import 196 tables are registered and only the four modules nothing imports (analytics, conversation_history_model, file, system_error_model) stay out of the hermetic schema. A new model is added to this file in the same commit that adds the migration, and a new service imports every model module here from the first one, so that a table's presence in the test schema does not depend on which router happened to import its module.
core/database.py holds two session paths with identical shape: get_session() (the FastAPI dependency behind get_db, lines 340 to 361) and get_db_context() (for code outside a request, 444 to 461). Neither commits; both roll back on an exception and expunge_all() before close(). The endpoint or service commits explicitly.
api/surface_gate.py is the module every invariant of this layer rests on, and a new service writes it to this specification rather than from the line numbers (read at fd8558f, 2026-09-11; a fresh service was rebuilt from it on 2026-09-12): (a) class SurfaceGate holds allowed: set[str], run through validate_surfaces in __init__ (66 to 67); its __call__(self, conn: HTTPConnection) -> None is a plain def (108) that reads the credential in S5's order (Authorization: Bearer, then the access_token cookie, then X-API-Key; 74 to 79 and 113 to 114), returns on no credential and on an undecodable token (116 to 118, 128 to 133), raises 403 when the token's aud is outside allowed (142 to 152), reads the grants (154 to 172; 503 on a lookup error), refuses a token whose aud is not granted and an API key whose owner holds no grant on any allowed surface (174 to 195), and writes conn.state.surface and conn.state.surface_grants (197 to 198). It declares HTTPConnection, not Request, so one instance covers HTTP and WebSocket routes, and _deny returns a WebSocketException(code=1008) for the latter (82 to 91). (b) class SurfaceExempt(reason, note="") accepts public or own_auth and raises ValueError on anything else (217 to 224); public delegates to a SurfaceGate over all four surfaces, own_auth is a no-op (240 to 252). (c) mount_router(app, router, *, surfaces, **kwargs): surfaces is a required keyword in the signature, so omitting it is a TypeError at import; the marker is surfaces itself when it is a SurfaceExempt and SurfaceGate(surfaces) otherwise; the marker is appended as Depends(marker) to kwargs["dependencies"] and app.include_router is called with the rest (265 to 274). (d) route_surface_marker(route) walks route.dependencies, reads each entry's .dependency and returns the first that is an instance of either class (277 to 283); ungated_routes(app) walks app.routes, considers only APIRoute and APIWebSocketRoute, skips _FRAMEWORK_PATHS (/openapi.json, /docs, /docs/oauth2-redirect, /redoc, /metrics, 54 to 60) and lists every route with no marker (286 to 297); assert_every_route_gated(app) raises RuntimeError beginning ADR-0020 §1: these routes were registered without a surface decision. followed by the list, else logs ADR-0020 surface gate: all %d routes carry a surface decision with len(app.routes) (300 to 316). (e) The module carries no from __future__ import annotations and says why at lines 31 to 37: FastAPI resolves a dependency's annotations against call.__globals__, which a class instance does not have, so a postponed HTTPConnection annotation arrives as a string and FastAPI treats the parameter as a query parameter, turning every gated route into a 422; the 2026-09-12 rebuild met exactly that with request: Request in a module carrying the future import. (f) Where the gate gets its session is the one place a new service departs from the reference: __call__ opens get_db_context() itself (109 and 154), which is why no dependency_overrides entry reaches it under pytest and why tests/test_surface_scoped_access.py builds committed rows on a plain SessionLocal() instead of the transactional db_session (lines 65 to 72, db.commit() at 129; S9 Gotchas). A new service declares the session as a parameter, def __call__(self, conn: HTTPConnection, db: Session = Depends(get_db)) -> None, so the single get_db override of S9's client fixture covers the gate as well as the handlers.
API surface
The layer itself owns the probes, three utility routes, the JWKS document and the mount rule. Every other route is a domain recipe's. None of these rows is tenant-scoped and none emits an error code of its own; the one envelope this layer's handlers produce on their own routes is the AUTH_001 shown below on /api/auth/me, which belongs to S5.
| Route | Auth dep | Tenancy | Surface decision | Status codes | Error codes |
|---|---|---|---|---|---|
GET / | none | none | surface_exempt("public", "service banner") (1109 to 1121) | 200 {name, version, status, environment, docs} | none |
GET /health, GET /health/live, GET /health/ready, GET /health/detailed | none | none | health_router (api/routers/health_router.py lines 16 to 106), mounted first at line 819 as exempt public | 200 always, on every one of the four; the readiness probe reports a failed database check in the body and not in the status (see below) | none |
GET /health and GET /health/ready declared again on app | none | none | exempt public (1123 to 1200); unreachable, the router's copies win by registration order (the docstring at 1128 to 1136 says so) | never served | none |
GET /r/{tracking_id} | none | none | exempt public (1081 to 1106) | 302 always, to the destination or the home page; the handler catches every exception (1101 to 1103) so no error body ever leaves it | none |
GET /.well-known/prima-jwks.json | none | none | exempt public, mounted before the catch-all clean URLs (1050 to 1062; at fd8558f the catch-all is declared and not mounted, Gotchas); GET only (api/routers/jwks_router.py line 60), HEAD is 405 without the headers | 200 with ETag and Cache-Control: public, max-age=120, 304 on If-None-Match | none |
GET /metrics | none | none | framework path, never gated (surface_gate.py 54 to 60) | only when ENABLE_METRICS | none |
The four probe bodies as served at fd8558f (health_router.py): /health is {status: "healthy", timestamp, environment, version, email_redirect_all_to} (16 to 38); /health/live is {status: "alive"} (40 to 45); /health/ready is {status: "ready", database: "healthy"} and, when db_manager.health_check() is false, the handler returns the tuple ({status: "not_ready", database: "unhealthy"}, 503), which FastAPI serialises as a two-element JSON array with HTTP 200 (46 to 63; measured 2026-09-11 in a scratch app, 200 [{"status":"not_ready","database":"unhealthy"},503]); /health/detailed is {status, timestamp, environment, version, system: {cpu_percent, memory, disk, python_version}, services: {database, redis, ai_service, email_service}} with status: "degraded" when the database check fails (65 to 106). A new service returns JSONResponse(status_code=503, content=...) from its readiness probe so an orchestrator can act on the status, and keeps the database value a string or a boolean but not one in one probe and the other in the next.
The mount rule: mount_router(app, router, *, surfaces, **kwargs) (surface_gate.py 265 to 274) appends Depends(marker) to the router's dependencies and calls app.include_router. surfaces is a set of surface ids (LABS_SURFACES, ALL_SURFACES from core/surfaces.py 177 to 180) or a surface_exempt(reason, note) where reason is public or own_auth and nothing else (202 to 252). Measured at fd8558f with the three flagged routers off and public_routes.py absent because its import fails (Gotchas): 981 API routes, 0 ungated; 920 behind a SurfaceGate (872 labs only, 48 all four surfaces), 59 exempt public, 2 exempt own_auth; grep -c "mount_router(" app_fastapi.py prints 93, grep -c "app.include_router" app_fastapi.py prints 1 and that line is commented out.
Envelopes this layer emits, all built by schemas/error_schemas.py::create_error_response (lines 112 to 135): {error, code, request_id, timestamp, details}. Which shape applies where is S7's subject; the handlers that choose are in app_fastapi.py 551 to 812: one per core/exceptions.py class (ServiceConfigurationError 503 with missing_env in details, NotFoundError 404, ValidationError 422, ForbiddenError 403, UnauthorizedError 401, ConflictError 409, RateLimitExceededError 429, ServiceUnavailableError 503, AlchemyException 500 with the message), then HTTPException (a dict detail keeps its keys in details and its error key becomes the message, 768 to 787; under /api/prima/v1 the same handler emits the OpenAI object shape, 711 to 766), then Exception (500, "Internal server error", never str(exc), 790 to 811). Error responses reflect the CORS origin by hand (691 to 708) because Starlette's error middleware sits outside CORSMiddleware.
Two outcomes bypass those handlers on the reference, measured 2026-09-11 at fd8558f. A path no route matches answers Starlette's own 404 {"detail":"Not Found"} (with the X-Request-ID header, without the envelope): the handler at 724 is registered for fastapi.HTTPException, a subclass of starlette.exceptions.HTTPException, and the router raises the base class, which the subclass handler does not catch. A handler that raises HTTPException itself does get the envelope (GET /api/auth/me without a token is 401 {error, code: "AUTH_001", request_id, timestamp, details: null}). And an exception that escapes a route never reaches the Exception handler at 790, because ErrorLoggingMiddleware (middleware/error_logging_middleware.py 145 to 180) wraps call_next in except Exception at 167 and answers {detail, error_id, timestamp} itself, with no request_id; S7 records the same. A new service registers the envelope handler on starlette.exceptions.HTTPException so the unmatched path is enveloped too, and does not wrap call_next in a bare except, so a crash renders S7's shape B through the Exception handler with the request id.
Headers every response carries: X-Request-ID (set by UnifiedMiddleware, api/middleware/unified_middleware.py lines 58 and 103) and the security headers from SecurityHeadersMiddleware. expose_headers=["X-Total-Count", "X-Request-ID"] on the CORS middleware (452).
Application logic
The composition order of app_fastapi.py, top to bottom, is the order a new service copies:
load_dotenv()(line 7), then Sentry ifSENTRY_DSN(10 to 31), before any project import.- The router imports (47 to 122) as one block, then the four middleware imports (125 to 128); thirteen
try/except ImportErrorblocks covering fourteen optional routers set an_AVAILABLEflag each (166 to 267;sed -n '160,270p' app_fastapi.py | grep -c 'except ImportError'prints 13, the block at 223 imports two routers).from api.surface_gate import assert_every_route_gated, mount_router, surface_exemptandfrom core.surfaces import ALL_SURFACES, LABS_SURFACES(134 to 140). from core.config import settings(142),get_key_set(143),init_db, close_db(144); logging to stdout (146 to 163).lifespan(269 to 385):init_db()(which isverify_schema_at_head()outside pytest,core/database.py289 to 330), the email transport selection logged (ADR-0019, ADR-0023), demo seed ifSEED_DEMO_DATA, Redis init (raises only in production), tracing,init_scheduler()in atry(S12), the WebSocket fan-out listener; on shutdown the reverse.app = FastAPI(...)(387 to 395) with docs behindENABLE_DOCS.- CORS: the allow-list plus every surface origin,
allow_credentials=True, never a wildcard (399 to 453); then thepublic_endpoint_corsmiddleware that answersOPTIONSitself and reflects any origin on thePUBLIC_CORS_PREFIXEStuple (460 to 505), which lists unauthenticated embed and beacon paths only. - Middleware, added in this order:
CORSMiddleware(446), the public-CORS function (484),SecurityHeadersMiddleware(507),TrustedHostMiddlewarein production (510 to 514),ErrorLoggingMiddleware(518),UnifiedMiddleware(523: request id, request log, rate limit),WorkspaceMiddleware(528 to 533,X-Workspace-ID, path extraction on, subdomain off). Starlette'sadd_middlewareinserts at the outside, so at runtime the last added runs first: a request passesWorkspaceMiddleware, thenUnifiedMiddleware, then error logging, host check, security headers, and reachesCORSMiddlewarelast before routing. That is the reference's order, not the one a new service copies. A new service adds, in this order:CORSMiddleware, the public-CORS function, security headers,TrustedHostMiddlewarein production, and the request-context middleware (request id, request log, rate limit) as the lastadd_middlewarecall, so it runs outermost and every refusal from a layer inside it carriesX-Request-ID(S7 Deploy & ops); it has no error-logging wrapper (theExceptionhandler renders a crash, API surface) and no workspace middleware (S6 Stack assumptions: a new app builds the dependency only). On the reference the request-id middleware is fourth of five withWorkspaceMiddlewareoutside it, which is why a workspace refusal carries no id (S6, S7). - Exception handlers (551 to 811) as in API surface.
app.mount("/static", StaticFiles(...))when the folder exists (813 to 816).mount_routerfor every router (819 to 1062): health first, auth and WebAuthn onALL_SURFACES, the product routers onLABS_SURFACES, webhooks and embedssurface_exempt("public", why), the WebSocket routersurface_exempt("own_auth", why)because the handshake carries a ticket the gate cannot see, the gateway routers onALL_SURFACES(892, 907), the flagged routers insideif settings.X_ENABLED:with their owntry/exceptso a load error never breaks boot (986 to 1041), JWKS before the catch-all (1050 to 1062)._labs_key_set = get_key_set()at import (1065) so a misconfigured signing key fails boot, not the first login.- The catch-all clean URLs last among routers (1076 to 1078; declared, not mounted at fd8558f because the import at 262 fails and
PUBLIC_ROUTES_AVAILABLEis false, Gotchas), then the four routes defined onappitself withdependencies=[Depends(surface_exempt(...))](/r/{tracking_id}1081,/1109,/health1123,/health/ready1152, to 1200), then/metrics. assert_every_route_gated(app)(1234) as the last statement that touchesapp. It walksapp.routes, skips the five framework paths (/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/metrics;_FRAMEWORK_PATHS,surface_gate.py54 to 60), and raisesRuntimeErrorlisting everyAPIRouteorAPIWebSocketRoutewithout aSurfaceGateorSurfaceExemptin its dependencies (surface_gate.py277 to 316). TheNin the log line islen(app.routes)(line 316), mounts and framework routes included; only theAPIRouteandAPIWebSocketRouteentries among them were walked (line 290).uvicorn.run("app_fastapi:app", ...)under__main__for development; in a container theDockerfileCMDisuvicorn app_fastapi:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WEB_CONCURRENCY:-2} --proxy-headers --forwarded-allow-ips='*'(line 117).
How a request flows once running: middleware (7) in order, then the router-level SurfaceGate dependency (it runs before the endpoint's own dependencies because include_router prepends it), then get_current_user or whichever S5 alias the handler declares, then WorkspaceID if the route is tenant-scoped, then the handler, which calls a services/ function with the session and the ids it resolved, and commits.
The second reference shape, for a service that starts small (D3: the standalone Prima gateway at cd2c248 is the PrimaAPI reference; the in-Labs services/primaapi copy is legacy): prima/app.py::create_app(settings) is a factory (lines 281 to 366) with build() as the uvicorn prima.app:build --factory entry (369 to 376); load_settings() (prima/config.py 1093) is a plain dataclass loader whose every refusal is a ConfigError (line 137) raised by the parse helpers (146 to 424; _node_peers at 224 to 250 is the duplicate-node refusal) before the app exists; the lifespan registers health checks, loads the JWKS from disk before the network and starts the credit sweep. prima/schema_guard.py refuses to run Prima's Alembic chain against a database that holds Labs tables (VERSION_TABLE = "prima_alembic_version", assert_not_labs_database). Prima mounts its six routers with plain include_router (345 to 364) and has no boot assertion; that is a measured deviation of the reference, not a second sanctioned shape. It holds together because its routes are of three kinds and each is gated somewhere else: the v1 routes declare CurrentIdentity, whose resolver carries the allowed surface set itself (S5, prima/api/auth.py 108 to 144); internal.py and keys_sync.py are HMAC seams (S10); health.py is three public probes. A new service copies whichever entry-point shape matches its size, the Labs file or the prima factory, and in either case adopts api/surface_gate.py (mount_router, assert_every_route_gated) so the surface decision is on the mount and not inferred from the handler's dependency; both shapes boot-refuse on bad configuration and both name their store.
Invariants
- Every router is mounted through
mount_routerwith asurfaces=decision;assert_every_route_gated(app)is the last statement andtests/test_surface_scoped_access.py::test_no_router_is_mounted_with_raw_include_router(line 762) fails on a rawinclude_router. Labs holds this at fd8558f; the standalone prima service does not yet (Application logic, second shape), and a new service holds it from its first router. - A router is one
APIRouterin one module underapi/routers/(or one of the two documented homes:api/forms/,services/primalayered/), mounted once from the entry point. Three modules deviate at fd8558f and are listed in Gotchas. - Store, transport and key source are named:
JWT_SIGNING_KEY_SOURCE,EMAIL_TRANSPORT,LLM_PROVIDER,PRIMA_LLM_PROVIDER. An unknown value raises; the presence of a credential is never consent (ADR-0019;core/jwt_keys.pylines 1 to 20 state the rule for keys). - Settings are a Pydantic
BaseSettingswith credentials typedSecretStr; a value that must not be a development default in production has a validator that raises in production; the instance is built once at import. - Boot verifies the schema (
verify_schema_at_head(), commit 99709a9, 2026-08-02) and refuses a database that is behind or ahead of the code's Alembic head; nothing at boot creates or patches a table outside pytest. - A missing signing key, an unreadable key file or an ephemeral key outside development stops the process at import (
get_key_set()atapp_fastapi.py1065). - Client-facing errors carry
request_idand neverstr(exc)of an unhandled exception. On the reference a crash carrieserror_idinstead ofrequest_idbecauseErrorLoggingMiddlewareanswers it before theExceptionhandler can (API surface, S7); a new service lets the handler render it. - A feature behind a flag is not mounted when the flag is off; its routes do not exist, they do not 403.
- The catch-all
/{workspace}/{form}routes are mounted last; anything that must be reachable at a two-segment path (JWKS) is mounted before them. On the reference at fd8558f the catch-all is declared and not mounted, so this ordering is untested there (Gotchas). - Neither session path commits; an endpoint that changes state calls
db.commit()itself.
Frontend
This layer has no page of its own. What renders it:
ui-design-system/ui/appshell-navigation the S2 shell: every private page it frames is a mounted router
reached through the api singleton; /api/auth/me is the contract
monitoring-ops/ui/public-status-page (planned) /health and /health/ready as a status row, the boot log lines
"surface gate: all N routes carry a surface decision" and
"JWKS mounted at ... (alg=EdDSA, source=..., active kid=...)"
The minimum debug surface a verifier uses without a page: GET / (banner with environment), GET /health/ready ({"status":"ready","database":"healthy"}, always 200 on the reference), GET /api/docs when ENABLE_DOCS (the OpenAPI list, on which every route appears under its router tag), and the boot log.
Security & tenancy
Auth dependency: none at this layer; S5 supplies it. What this layer guarantees is that the surface gate runs on every route before S5's dependency does, and that anonymous requests pass the gate untouched so the endpoint's own dependency produces the 401 (surface_gate.py 116 to 118 and 128 to 133).
Workspace derivation: S6. The middleware at step 7 identifies the caller from the JWT or the X-API-Key hash and validates the header against membership (middleware/workspace_middleware.py at fd8558f, measured 2026-09-11: _extract_user_id 221 to 282, _validate_workspace_access 284 to 365); the dependency re-validates when the middleware set no state (an excluded path, or no header); the layout only fixes where each lives.
Response headers: SecurityHeadersMiddleware (api/middleware/security.py 66 to 90) sets X-Content-Type-Options: nosniff, X-XSS-Protection, Strict-Transport-Security: max-age=31536000; includeSubDomains, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy that denies geolocation, microphone, camera, magnetometer, gyroscope and payment, and the framing pair: X-Frame-Options: DENY with Content-Security-Policy: frame-ancestors 'none' by default, or frame-ancestors * alone on the embed and file-preview prefixes the middleware lists (46 to 58), because a widget that must be iframed anywhere cannot carry DENY.
Rate limits: UnifiedMiddleware._check_rate_limit (api/middleware/unified_middleware.py 143 to 192) keys on the client IP from the trusted proxy chain and applies two ceilings per 60-second window, RATE_LIMIT_IP_PER_MINUTE (default 1000, core/config.py 811) on every /api/ path and RATE_LIMIT_PUBLIC_IP_PER_MINUTE (default 240, line 815) on the public prefixes; the auth prefixes are skipped there and carry auth_rate_limit instead (Gotchas). A hit is 429 {error: "rate_limit_exceeded", message} with Retry-After; a limiter error lets the request through.
Fail direction, stated per seam as the inventory's convention asks: settings validation closed; schema check closed; signing key closed; surface grants lookup closed (503 "Access control is temporarily unavailable.", surface_gate.py 163 to 172); Redis closed in production and open elsewhere (lifespan 301 to 311); rate limiting open when the limiter is not initialised (UnifiedMiddleware); optional router import open by design (a missing module logs a warning and the feature is absent).
Credentials at rest: SecretStr fields are not printed by repr; the module-level exports at the bottom of core/config.py (1061 to 1063) unwrap three of them for legacy importers and are the place a new service does not copy. The signing key arrives as env or file and is never logged (core/jwt_keys.py --generate prints the private half once, to stdout, for the operator to move).
Attacker-controlled inputs this layer touches: the Origin header (reflected only for PUBLIC_CORS_PREFIXES paths, 484 to 505, and on error responses only when it is in the allow-list, 691 to 708), the Host header (TrustedHostMiddleware in production), X-Forwarded-For (--proxy-headers --forwarded-allow-ips='*' in the container, TRUSTED_PROXY_COUNT for the rate limiter), and the two-segment catch-all path (declared, not mounted at fd8558f, Gotchas).
CORS: allow_credentials=True with explicit origins for authenticated calls; the wide-open reflection is a separate middleware scoped to a tuple of public prefixes, and an authenticated path must never be added to that tuple (the comment at 457 to 459 says so, and /api/scribe/config is the named example).
Tests
Layout: backend/tests/test_<area>_*.py (72 test files at fd8558f, in a folder of 83 entries with conftest.py, desk_support.py and nine subfolders), tests/v2/coverage/ for the second-wave suites, tests/conftest.py sets ENVIRONMENT=development, REDIS_ENABLED=false and points DATABASE_URL and SYNC_DATABASE_URL at the test Postgres on :5434 with the fixture credentials from docker-compose.test.yml (conftest lines 27 to 50; the fixture is docker-compose.test.yml lines 118 to 122, port mapping at 122). pytest.ini adds coverage flags by default; pass -o addopts="" to run a subset without them. S9 owns the rest.
What this layer ships and a new service copies:
tests/test_surface_scoped_access.py:test_every_route_carries_a_surface_decision(680),test_a_router_mounted_without_a_gate_fails_the_boot_assertion(688),test_mount_router_makes_the_surface_decision_unforgettable(707),test_the_grants_table_is_read_in_exactly_one_module(716),test_no_router_is_mounted_with_raw_include_router(762). Run 2026-09-11 withtests/test_surface_rp_id.py:84 passed in 8.27s.tests/test_prima_ed25519_interop.py:test_signing_key_source_has_no_fallback_chain(659),test_ephemeral_keys_are_refused_outside_development(671),test_file_source_reads_the_key_from_disk(687). Run 2026-09-11 withtests/test_admin_api_key_scopes.py:59 passed in 3.52s.- Known-answer vectors: the JWKS document and a token minted by Labs and verified by Prima's own verifier (
test_labs_token_verifies_through_primas_own_verifier, 160). Hermetic: everything above needs the:5434Postgres for the app fixture and nothing else; no test reaches a deployed host. - The boot assertion itself is a test that runs on every start: a process that logs
ADR-0020 surface gate: all N routes carry a surface decisionpassed it.
Playwright gate: none for this layer; a domain recipe that is user-facing adds one under S2's frontend/e2e/.
Deploy & ops
Config keys per app: the table under Stack assumptions is the minimum a heredoc for a new service carries; deploy/infomaniak/ENV_CHECKLIST.md in the reference repo lists the full set for Labs. Migrations run before the image swap, never at boot: the Dockerfile (lines 60 to 70) documents that scripts/migrate.sh is invoked once per rollout by the platform (a compose one-shot service, a k8s Job, a release command) because the web tier runs several replicas that would race on DDL, and verify_schema_at_head() then refuses to serve against a database that was not migrated. deploy/infomaniak/docker-compose.infomaniak.yml shows the shape: a migrate service with command: ["/app/scripts/migrate.sh"] and restart: "no" (lines 33 to 42), then backend with ENVIRONMENT: production, WEB_CONCURRENCY, the port published as "${API_PORT:-8000}:8000" (line 69, all interfaces; grep -c 127.0.0.1 deploy/infomaniak/docker-compose.infomaniak.yml prints 0 at fd8558f, and the seven loopback references elsewhere in that folder are the host Caddy's reverse_proxy 127.0.0.1:8000 target, Caddyfile.staging line 121, not a compose bind) and healthcheck: curl -f http://localhost:8000/health (48 to 78). That healthcheck, the Dockerfile HEALTHCHECK (line 104) and the Argus probe reach the process by loopback with Host: localhost or Host: 127.0.0.1, while ALLOWED_HOSTS in production feeds TrustedHostMiddleware (core/config.py 207 to 215, whose example value is the public hostnames only; app_fastapi.py 510 to 514) and deploy/infomaniak/ENV_CHECKLIST.md line 60 says no more than TrustedHost list (prod). Built exactly as written, the container's own probe answers 400. A new service either lists localhost and the container hostname in ALLOWED_HOSTS or sends -H 'Host: <public host>' from the healthcheck and the Argus row; S13's API surface table records the symptom on the reference (Labs prod answers 400 on 127.0.0.1 because of the trusted-host list) from the operator's side. scripts/migrate.sh takes a session-level Postgres advisory lock (pg_advisory_lock, line 60) around alembic upgrade head so two invocations cannot interleave.
Worker count: WEB_CONCURRENCY in the container (default 2); backend/start.sh derives 1, 2 or 4 from the cgroup memory limit (lines 9 to 15, WORKERS at 34 to 36) for the older platform. With JWT_SIGNING_KEY_SOURCE=ephemeral each worker would sign with its own key; that is why the value is refused outside development (Gotchas).
Scheduler: init_scheduler() in the lifespan under a try; leadership and the job table are S12. The compose file sets SCHEDULER_ROLE per container (migrate on the one-shot at line 41, ${SCHEDULER_ROLE:-worker} on backend at 67), but at fd8558f no backend module reads it (grep -rn SCHEDULER_ROLE backend --include='*.py' prints nothing), and the compose comment at 62 to 66 says so: the guard around init_scheduler() is a planned change and until it lands the estate runs one backend replica. S12 carries the guard as the target; do not treat the variable as a working control.
Argus checks a new service registers: GET /health 200, GET /health/ready 200 with the body's database equal to "healthy" (on the reference the status is 200 either way, so the check reads the body; on a new service built to this recipe a failed dependency is a 503), the JWKS document parseable with at least one key, and the boot log line from assert_every_route_gated. Rollback is schema first, then image, in that order (S8 Deploy & ops step 7, S13 Deploy & ops): alembic downgrade <previous head> from a one-off container of the image that shipped the revision (the old image cannot run that downgrade(), its versions/ directory does not hold the file), alembic current from the same container to confirm the previous head, then the retag to the old image and compose up -d. Re-pointing the tag first leaves the old image refusing to boot against the newer schema: the Database is not at Alembic head line it prints is the schema guard doing its job, not a bug, and it stays until the downgrade has run.
Ports: the API listens on 8000 inside the container. The reference compose publishes it on every interface (above); a new service binds 127.0.0.1:${API_PORT}:8000 so that only the host Caddy in front of it (S13) can reach it.
The two files a new service copies, reduced to what the reference does at fd8558f (Dockerfile lines 23 to 117, scripts/migrate.sh lines 34 to 77) with the Labs-only lines left out (the matplotlib cache, the malloc tuning, the /var/data file store, the emoji in the script's messages):
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
RUN useradd -m -u 1000 app
COPY --chown=app:app backend/ ./backend/
COPY --chown=app:app scripts/ ./scripts/
RUN chmod +x /app/scripts/migrate.sh
ENV PORT=8000 WEB_CONCURRENCY=2 PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 CMD curl -f "http://localhost:${PORT}/health" || exit 1
WORKDIR /app/backend
CMD ["/bin/sh", "-c", "exec uvicorn app_fastapi:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WEB_CONCURRENCY:-2} --proxy-headers --forwarded-allow-ips='*' --log-level info"]
The order is the reference's: dependencies installed with --prefix=/install in a build stage and copied to /usr/local so console scripts (alembic, uvicorn) run for the non-root user (lines 30 to 33 and 47); useradd before the COPY --chown lines (54 to 58; the comment there records the whole-image rewrite a trailing chown -R caused); migrate.sh copied and made executable but never in CMD (60 to 70); the healthcheck reaches /health by loopback with Host: localhost (104 to 105, hence the ALLOWED_HOSTS rule above); WORKDIR /app/backend before CMD because the modules import api.* and services.* bare (112 to 116).
#!/bin/sh
set -eu
BACKEND_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../backend" && pwd)"
cd "$BACKEND_DIR"
exec python - <<'PY'
import subprocess, sys
from sqlalchemy import text
from core.database import engine
MIGRATION_LOCK_ID = 8110731
with engine.connect() as conn:
conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": MIGRATION_LOCK_ID})
try:
result = subprocess.run([sys.executable, "-m", "alembic", "upgrade", "head"])
finally:
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": MIGRATION_LOCK_ID})
if result.returncode != 0:
print("alembic upgrade head FAILED; the app refuses to boot against a database that is not at head", file=sys.stderr)
sys.exit(result.returncode)
print("database is at Alembic head")
PY
The session-level advisory lock (MIGRATION_LOCK_ID = 8110731, reference lines 55 to 64) is what makes a retried release command or a second operator wait instead of running the same DDL twice; the script exits non-zero when Alembic does, so the deploy aborts before the image swap; the last line is the string S13's check 5 reads.
Gotchas
- 2026-09-11 (measured at fd8558f, code wins over the wiki):
platform/architecture.md(d4f5b95, page updated 2026-08-05) says the ORM usesNullPooland that production boots oncreate_all()plusrun_pending_migrations(). The sync engine has usedQueuePool(2, 3)since b71e1ba (2026-06-10) andrun_pending_migrationswas retired by 99709a9 (2026-08-02) in favour ofverify_schema_at_head(); the function still exists as_run_pending_migrations_RETIRED(core/database.pyline 69) and is called by nothing. - 2026-09-11 (measured at fd8558f, code wins over the wiki): the same page lists six middleware "outer to inner" in the order they are added, which is inner to outer at runtime (step 7 above), calls the router set "40+" and the API "891 routes". At fd8558f there are seven middleware including the public-CORS function, 90
_router.pymodules pluspublic_routes.pyunderapi/routers, three more routers mounted fromapi/formsandservices/primalayered, and 981 API routes with the three flagged routers off. - 2026-09-11 (measured at fd8558f):
api/dependencies/is a package that shadowsapi/dependencies.py. Its__init__.pyloads the sibling file by path under the nameapi._dependencies_impland re-exports every public name (lines 4 to 39).from api.dependencies import Xworks;from api.dependencies.rate_limit import auth_rate_limitworks; a test that needs a private helper importsapi._dependencies_impl(tests/test_desk_acting_email.py592). A new service does not reproduce the shadow: it starts withapi/dependencies/__init__.pyholding the code and nothing to load by path. - 2026-09-11 (measured at fd8558f):
RateLimitDependencyinapi/dependencies.py(425 to 461) is a placeholder whose__call__ends inpass;rate_limit_defaultandrate_limit_strictlimit nothing. The real limiters areUnifiedMiddleware(per-IP, Redis with in-memory fallback) andapi/dependencies/rate_limit.py::auth_rate_limit(30 per minute per IP, 10 per minute per email, 429 withRetry-After) on the credential-issuing auth routes. Do not copy the placeholder. - 2026-09-11 (measured at fd8558f):
GET /healthandGET /health/readyare each declared twice, inhealth_router(paths at lines 16 and 46, mounted atapp_fastapi.py819) and again onapp(1123 to 1200); Starlette serves the first registered, so the two inapp_fastapi.pynever run. The/healthdocstring says so; the/health/readyone does not. A new service declares each probe once, in the router. - 2026-09-11 (measured at fd8558f): the readiness probe that is served cannot return 503.
health_router.py56 to 59 returns({"status": "not_ready", "database": "unhealthy"}, 503), a tuple, and FastAPI serialises a tuple as the response body, so the failure answer is HTTP 200 with a JSON array (measured in a scratch app:200 [{"status":"not_ready","database":"unhealthy"},503]). The 503 with achecksobject lives only in the unreachable copy atapp_fastapi.py1152 to 1200. An orchestrator watching the status code never sees this service go unready. A new service returnsJSONResponse(status_code=503, content=...)and tests the failure branch. - 2026-09-11 (measured at fd8558f): an unmatched path is not enveloped.
GET /api/anything-that-does-not-existanswers404 {"detail":"Not Found"}because theHTTPExceptionhandler (724) is registered onfastapi.HTTPExceptionand Starlette's router raisesstarlette.exceptions.HTTPException, the base class. S7's shape table has no row for it; this Gotcha is the record. A new service registers the handler on the Starlette class. - 2026-09-11 (measured at fd8558f): fourteen routers, in thirteen
try/except ImportErrorblocks, are imported and skipped with a warning when the import fails (166 to 267). This turns a typo informs_router.pyinto a running service without a forms API; the next Gotcha is the live instance. The pattern predates the surface gate; a new service imports every router unconditionally and lets a broken import fail the boot. - 2026-09-11 (measured at fd8558f):
api/routers/public_routes.py, the clean-URL catch-all (/{workspace}/{form},/{workspace}/book/{meeting}), does not mount. Line 13 isfrom core.database import get_dbandcore/database.pyhas no such name (get_sessionat 340,get_db_contextat 444);python -c "from api.routers import public_routes"raisesImportError: cannot import name 'get_db' from 'core.database', the guard at 261 to 266 logsPublic routes not available - clean URLs disabled,PUBLIC_ROUTES_AVAILABLEis false and themount_routerat 1077 never runs. The file was last touched at f6862d2 (2026-04-01), before fd8558f. So the reference serves no clean URLs, the 981 routes and 59 exempt-public routes above are counted without it, and the JWKS-before-catch-all ordering is stated here from the source order, not from a served route. A new service has no optional-router guard to hide this; the boot fails on the import. - 2026-09-11 (measured at fd8558f): the folder names promise a layering the code does not enforce.
grep -rl "from fastapi import" services | grep -v "primalayered\|primaapi" | wc -lprints 27, and 14 files underapi/routersimport from another router module. Nothing fails on either; the wiki's "business logic layer" is a description, not a gate. A new service that wants the layering writes an import-graph test for it (Prima'stests/auth/test_import_graph.pyis the shape). - 2026-09-11 (measured at fd8558f):
core/config.pyline 36 bakes a default workspace id as a literal (DEFAULT_WORKSPACE_ID) that must match a database default. A new service has no such literal; the tenant of a public write is derived from the resource row (S6). - 2026-09-11 (measured at fd8558f with an
astwalk overapi/routers/*.py,api/forms/*.pyandservices/primalayered/**/*.py, counting every function whose decorator is<name>.get|post|put|patch|delete|websocket|api_route): 507 handlers areasync defand 541 aredef, and both kinds take the synchronous session fromget_db. Anasync defhandler that touches the session blocks the event loop for the duration of the query; adefhandler runs in the threadpool. The wiki's 2026-07-22 audit counted about 296 of the async kind touching SQLAlchemy. A new handler that uses the session isdef. - 2026-09-11 (measured at fd8558f): the module-level exports at the end of
core/config.py(SECRET_KEY = settings.SECRET_KEY.get_secret_value()and two more, lines 1061 to 1063) unwrap credentials into plain module globals for backward compatibility. A new service exportssettingsand nothing else. - 2026-09-11 (measured at fd8558f):
app_fastapi.pyis 1246 lines because the mounts, the handlers and the CORS list are all in it. The reference for a new service keeps the same order in the same file until the file passes about 300 lines, then moves the mounts intoapi/mounts.py::mount_all(app)and keeps the boot assertion in the entry point. Not intoapi/routers/__init__.py: amount_allthere has to import every router fromapi.routers, and Acceptance check 10 (grep -rl "from api.routers" api/routersprinting0) is meant to hold on a new service; the 2026-09-12 rebuild put it in the package__init__and the grep printed1. - 2026-09-11 (measured at cd2c248, prima): the standalone service's
Settingsis a dataclass loaded by hand, not aBaseSettings; the refusals are the same in kind (empty value, bad URL, out-of-range integer, duplicate node) and happen inload_settings()beforecreate_app()returns. Two settings styles exist in the estate; a new service picks the Pydantic one unless it must stay dependency-free. - 2026-09-11 (measured at fd8558f):
models/__init__.pyimports 36 of the 57 model modules. The 21 it does not import, and which nothing imports transitively, areadmin_action_model,analytics,brand_essence_model,calendar_integration_model,content_review_model,conversation_history_model,dashboard_model,emdash_site_model,file,onboarding_v2_model,outreach_model,primaapi_models,primalayered_spend_models,project_chat_model,retargeting_model,seo_models,system_error_model,system_log_model,system_settings_model,token_voucher_model,waitlist_model. Their tables are absent fromBase.metadataafterimport modelsalone (152 tables registered,outreach_linksamong the missing). The pytest schema does not start from there:tests/conftest.py::test_engineimportsapp_fastapi(line 97) beforecreate_all()(line 103), the routers load 17 of the 21 transitively (api/routers/outreach_router.pyline 16 is the one that brings inoutreach_model), and after that import 196 tables are registered. The four modules no router or service imports,analytics,conversation_history_model,fileandsystem_error_model, are the ones missing from the hermetic schema; a test that touches one of their tables is the test that fails. The migrations carry all of them, so production is unaffected. A new service lists every model module inmodels/__init__.py, so the schema is complete onimport modelsand not by way of the routers. - 2026-09-11 (measured at fd8558f): three router modules deviate from the one-router-one-module invariant.
api/routers/service_desk_router.pydefinesrouterandconfig_router(lines 72 and 73), mounted separately (826);api/routers/tracking_router.pydefinesrouterandlegacy_router(260 and 263), mounted separately (952);api/routers/desk_login_router.pyis not mounted from the entry point at all but nested into the desk router with_desk.router.include_router(router)(line 599).api/forms/template_router.pyandapi/forms/submission_router.pyare exported byapi/forms/__init__.pyand mounted nowhere. A new service has none of the four shapes. - 2026-09-11 (D1a note): commits fd105ee and fd8558f touch nothing under
backend/(fd105ee also edits.gitignoreandCLAUDE.mdat the repo root, the rest isfrontend/);git diff --stat b1089b6 fd8558f -- backendis empty, so the backend tree measured here is the treefeat/service-deskships at b1089b6.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/app_fastapi.py: Sentry (10 to 31), router imports (47 to 122), middleware imports (125 to 128), gate imports (134 to 140), logging (146 to 163), optional routers (166 to 267),lifespan(269 to 385),app(387 to 395), CORS (399 to 453),PUBLIC_CORS_PREFIXESandpublic_endpoint_cors(460 to 505), middleware (507 to 533), handlers (551 to 811), static (813 to 816), mounts (819 to 1062), key set at boot (1065 to 1073), catch-all and the four app routes (1076 to 1200), metrics (1213 to 1224),assert_every_route_gated(1234).backend/api/surface_gate.py:SurfaceGate(63 to 199),SurfaceExempt(202 to 252),mount_router(265 to 274),route_surface_marker,ungated_routes,assert_every_route_gated(277 to 316).backend/core/surfaces.py: ids (34 to 38),Surface(41 to 122),SURFACES(125 to 166), the frozen sets (169 to 189),validate_surfaces(223 to 235),all_surface_origins(353 to 364).backend/core/config.py:Settings(16),ENVIRONMENT(30), JWT block (58 to 131), validators (176 to 350, 417 to 435, 493 to 565, 629 to 655, 790 to 825),settings = Settings()(1058).backend/core/database.py: engine (50 to 58),SessionLocal(61 to 66),verify_schema_at_head(250 to 282),init_db(289 to 330),get_session(340 to 361),get_db_context(444 to 461).backend/core/jwt_keys.py: the source rule (1 to 20),build_key_set(245 to 325),get_key_set(332 to 344).backend/api/dependencies.pyandbackend/api/dependencies/__init__.py: S5 and S6 anchor here; the shadow is described above.backend/models/__init__.py(230 lines),backend/schemas/error_schemas.py(create_error_response112 to 135),backend/core/exceptions.py.backend/api/middleware/unified_middleware.py(request id 58,X-Request-ID103,_check_rate_limit143 to 192),backend/api/middleware/security.py(headers 66 to 90),backend/middleware/error_logging_middleware.py(ErrorLoggingMiddleware145 to 180),backend/middleware/workspace_middleware.py(_extract_user_id221 to 282,_validate_workspace_access284 to 365,_requires_workspace371 to 390,get_workspace_id393).backend/api/routers/health_router.py(16 to 106),backend/api/routers/jwks_router.py(route at 60).backend/tests/conftest.py(27 to 50,REDIS_ENABLED36,test_engine57 to 103),backend/pytest.ini,docker-compose.test.yml(122).Dockerfile(base image 23 and 38, 60 to 70, 104 to 117),scripts/migrate.sh(advisory lock 60),backend/start.sh,deploy/infomaniak/docker-compose.infomaniak.yml(migrate33 to 42,backend48 to 78;SCHEDULER_ROLE41 and 62 to 67, port 69, healthcheck 76 to 78),deploy/infomaniak/ENV_CHECKLIST.md(60),deploy/infomaniak/Caddyfile.staging(121).
prima(feature/per-model-pricing, cd2c248):prima/app.py(create_app281 to 366,include_routercalls 345 to 364,build369 to 376),prima/config.py(ConfigError137, the parse helpers 146 to 424 with_node_peers224 to 250,Settings840,load_settings1093),prima/schema_guard.py,prima/api/auth.py(current_identity108 to 144).- Wiki
philosophers_stone(d4f5b95):platform/architecture.md(stack table, repository structure, middleware list, router map, configuration groups; three of its claims are superseded, see Gotchas),platform/primaapi.md("The standalone service"),operations/decisions/0019-store-selection-is-explicit.md,operations/decisions/0020-prima-identity-and-prepaid-credits.md(section 1, the surface model),operations/prima-modules-inventory.md(section 1.1 row S4, section 2 backend conventions).
Deviations of the reference from this recipe, all dated 2026-09-11: the api/dependencies shadow package; the placeholder RateLimitDependency; the duplicate /health; a readiness probe that answers 200 on failure; an unmatched path that answers Starlette's {"detail":"Not Found"} instead of the envelope; a crash that answers error_id from ErrorLoggingMiddleware instead of request_id from the Exception handler; fourteen routers behind thirteen try/except ImportError blocks, one of which (public_routes.py, the clean-URL catch-all) fails its import and is not mounted; a fourth _ENABLED flag (INFOMANIAK_OIDC_ENABLED) whose router is mounted while off and answers 503; 21 model modules missing from models/__init__.py, four of them absent from the pytest schema; two dual-router modules, one nested router and two unmounted routers; the unenforced services and router layering; the literal default workspace id in settings; 507 async def handlers on a sync session; three unwrapped credential globals at the bottom of core/config.py; a 1246-line entry point; SCHEDULER_ROLE set by compose and read by nothing; the compose port published on every interface. On the prima side: routers mounted with plain include_router and no boot assertion.
Acceptance checks
Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.
grep -c "mount_router(" app_fastapi.pyprints93;grep -c "^app.include_router" app_fastapi.pyprints0;tail -20 app_fastapi.py | grep -c "assert_every_route_gated(app)"prints1. On a new service the first number is the number of routers it has.- With the environment variables from
tests/conftest.pyexported (ENVIRONMENT=development,REDIS_ENABLED=false, the two database URLs),python -c "import app_fastapi as m; from api.surface_gate import ungated_routes; print(len(ungated_routes(m.app)))"prints0and the log containsADR-0020 surface gate: all 982 routes carry a surface decision(Nislen(app.routes)as logged atsurface_gate.pyline 316, so the/staticmount counts and 982 is the 981 API routes plus that mount; the assertion walked only theAPIRouteandAPIWebSocketRouteentries, line 290; a new service prints its ownlen(app.routes)). - Remove the
surfaces=argument from any onemount_routercall and repeat check 2: the import raisesTypeErrornaming the missing keyword. Replace the call withapp.include_router(...)instead: the import raisesRuntimeErrorbeginningADR-0020 §1: these routes were registered without a surface decision. ENVIRONMENT=production JWT_SIGNING_KEY_SOURCE=ephemeral python -c "import core.config"exits non-zero with a message containingJWT_SIGNING_KEY_SOURCE='ephemeral' is not permitted when ENVIRONMENT='production'.ENVIRONMENT=nope python -c "import core.config"exits non-zero withString should match patternonENVIRONMENT.EMAIL_TRANSPORT=carrier-pigeon python -c "import core.config"exits non-zero withInvalid EMAIL_TRANSPORT. On a new service the check applies only when the service names an email transport; the reading is that every<THING>_TRANSPORT,<THING>_PROVIDERor<THING>_SOURCEsetting with a fixed value set refuses an unknown value at import, whatever the variable is called (ADR-0019).- Against a database whose
alembic_versionis not at head:python -c "from core.database import verify_schema_at_head as v; v()"raisesRuntimeErrorbeginningDatabase is not at Alembic head; against a database with noalembic_versiontable the message beginsDatabase has no alembic_version table. python -m pytest tests/test_surface_scoped_access.py tests/test_surface_rp_id.py -q --no-cov -o addopts=""prints84 passed;python -m pytest tests/test_prima_ed25519_interop.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts=""prints59 passed. On a new service the two files are its own gate tests, and the reading is that they pass and cover at least every-route-gated,TypeErrorwithoutsurfaces=,RuntimeErroron a rawinclude_routerand the key-source refusals (Tests); the counts are the service's own.- With the app running locally:
curl -s http://127.0.0.1:8000/returns JSON with"status":"running";curl -s http://127.0.0.1:8000/health/readyreturns{"status":"ready","database":"healthy"}with HTTP 200 (on a new service, stop the database and repeat: HTTP 503; on the reference the status stays 200, Gotchas);curl -si http://127.0.0.1:8000/.well-known/prima-jwks.jsonshows HTTP 200,etag: W/"andcache-control: public, max-age=120(curl -sI, aHEAD, is 405 with neither header);curl -s http://127.0.0.1:8000/api/auth/mereturns HTTP 401 with a JSON body whose keys areerror,code,request_id,timestampanddetails, and the response carries anx-request-idheader;curl -s http://127.0.0.1:8000/api/anything-that-does-not-existreturns404 {"detail":"Not Found"}on the reference (a new service that registers the handler on the Starlette class returns the envelope here too). curl -s -X OPTIONS -H "Origin: https://example.com" http://127.0.0.1:8000/api/f/x -i | grep -i access-control-allow-originprintshttps://example.com; the same against/api/auth/meprints nothing (the origin is not in the allow-list).- Layering, measured not enforced:
grep -rl "from fastapi import" services | grep -v "primalayered\|primaapi" | wc -lprints27at fd8558f and0on a new service that keeps the folder rule;grep -rl "from api.routers" api/routers | wc -lprints14at fd8558f and0on a new service. grep -c "^from models\." models/__init__.pyprints36at fd8558f againstls models/*.py | grep -v __init__ | wc -lprinting57(the 21 missing are listed in Gotchas). With the conftest variables exported,python -c "import models; from core.database import Base; print(len(Base.metadata.tables))"prints152and, afterimport app_fastapiin the same process,196; the model modules still not insys.modulesafter the app import are exactlyanalytics,conversation_history_model,fileandsystem_error_model, and their tables are the ones absent from the test schema. On a new service the two grep numbers are equal and the table count is the same before and after the app import; a model module that neithermodels/__init__.pynor any router or service imports is absent from the hermetic schema, and the first test that touches its table fails withrelation ... does not exist.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.