services/api/product_app.py is the executable target for the deployed backend. Its own module docstring states the reason it exists as a separate file from services/api/main.py: the historical app “also hosts the local research workbench, legacy file-backed client onboarding, and process-global /mvp1 routes. Importing that app therefore creates stores and exposes operations the customer/staff product does not need.” product_app.py builds a second, deliberately smaller FastAPI app from the same route modules, and lists every operation it publishes explicitly.
create_product_app(): the assembly
app = create_product_app() at module scope is the object uvicorn services.api.product_app:app (the repo’s Dockerfile CMD) actually serves. There is no additional route registration anywhere else in the deployed process.
The route-allowlist pattern
Every one of the seveninclude_router calls above passes the full router object from its source module (auth_router, onboarding_router, etc.) through _selected_router(), which filters it down to an explicit frozenset of (METHOD, path) tuples — AUTH_OPERATIONS, ONBOARDING_OPERATIONS, and so on. product_app.py also defines TENANT_OPERATIONS (the union of INSIGHT_OPERATIONS, MEMBER_OPERATIONS, SOURCE_OPERATIONS, and REMEDIATION_OPERATIONS) and MVP_ROUTE_OPERATIONS — the union of AUTH_OPERATIONS, ONBOARDING_OPERATIONS, TENANT_OPERATIONS, and OBSERVABILITY_OPERATIONS — as the single frozenset tests and contract checks treat as the full published surface. This is the load-bearing design decision in this file:
- Source routers hold more routes than the product ever exposes. For example,
onboarding_routeralso defines/{tenant_id}/rename,/{tenant_id}/materialize-now,/{tenant_id}/run-migrations, and/{tenant_id}/import-checkpoint— each one an operator/rehearsal escape hatch documented in its ownTEMPORARY:code comment (e.g.run-migrationsexists because this deployment has no direct external Postgres access from a local dev machine, so an already-provisioned tenant can be migrated to head over HTTPS instead) — and none of them appear inONBOARDING_OPERATIONS, so_selected_routerdrops them.auth_routersimilarly defines/admin/sessions,/admin/password-reset/*,/password-reset/*, and/dev/mailbox(a local-only mailbox reader) that never reach the product surface. _selected_routeris defensive in two directions, both enforced with aRuntimeErrorat import time (i.e., app boot fails loudly, not silently at request time):- Missing operations: if an allowlisted
(method, path)pair isn’t actually registered on the source router, boot fails with"{label} MVP operations are not registered". You cannot allowlist an endpoint that doesn’t exist yet. - Mixed methods on one path: if a path has some HTTP methods allowlisted and others not (e.g., a route registered for both
GETandDELETEwhere onlyGETis approved), boot fails with"{label} route {path!r} mixes allowed and unapproved methods". A path is all-or-nothing per method — there is no way to partially publish a route.
- Missing operations: if an allowlisted
Published surface: 44 operations
The published surface is exactly 44 HTTP operations: 41 allowlisted business operations plus 3 always-on system endpoints (/health, /health/live, /health/ready) registered directly on product outside the allowlist mechanism, since they carry no tenant or employee authorization and must always answer.
Each router’s own module docstring explains its authorization model and is the right place to read next:
auth_api.py— employee (Onboarding Portal) and tenant-user (Client Portal) session auth. Every mutating route depends onrequire_csrf; login/accept-invite are exempt because there is no session yet to protect.onboarding_api.py— the staff-facing tenant lifecycle and checklist pipeline. Every route requires an authenticated employee; mutations additionally require theonboarding_admincontrol role (support_readonlyemployees can read but not act).insights_api.py— tenant-facing insight reads backed only by materializedinsight_collections; never computes on request.members_api.py— tenant-facing member directory.metrics_api.py— Prometheus-format observability endpoint (job durations, queue depth, LLM usage), gated behindget_current_employee.remediation_api.py— the real, RLS-isolated, per-tenant CAP/review/audit-log system built oncaps/reviews/audit_logtables, replacing the legacy process-global/mvp1/capspath. Every mutation writes a tenantaudit_logrow.sources_api.py— tenant-facing source connector management. Credentials are write-only: encrypted on submission, never returned after creation (only a last-4-style hint).
TenantPrincipal/EmployeePrincipal and RLS enforce these boundaries, and API reference overview for request/response contracts.
Request observability
install_request_observability() (services/api/observability.py) is the only middleware the product app installs. It is deliberately small and provider-neutral:
- Accepts an inbound
x-request-idheader only if it matches^[A-Za-z0-9._:-]{1,128}$; otherwise generates a freshuuid4().hex. This prevents a client from injecting arbitrary content into structured logs via the header. - Stores the resolved ID on
request.state.request_id, echoes it back as thex-request-idresponse header, and emits exactly one structured JSON log line per request (event: "http_request", method, path, status code, duration in milliseconds) to thecauseloop.httplogger — in thefinallyblock, so it fires even when the handler raises.
The /health/ready model
The product app exposes three health routes, all defined directly in create_product_app():
GET /healthandGET /health/live— trivial liveness: always return{"status": "ok"}. Suitable for a container orchestrator’s liveness probe; they do not touch the database.GET /health/ready— the real readiness gate. It runs five independent checks and returns HTTP503with{"status": "not_ready", ...}the moment any one of them fails;200with{"status": "ready", ...}only when all pass.
1
database + worker heartbeat
Runs
SELECT 1 against the control-plane connection (fail-fast if Postgres is unreachable), then checks control.runtime_heartbeats for a row per CAUSELOOP_REQUIRED_WORKERS (default product-worker,provisioner-worker) heartbeated within the last CAUSELOOP_WORKER_READY_SECONDS (default 45s). This alone would only prove a worker process is alive — _worker_capabilities_ready() goes further and cross-checks each heartbeat’s advertised detail.job_types against REQUIRED_WORKER_JOB_TYPES (product-worker must advertise all of materialize_insights, send_email, train_tenant_model; provisioner-worker must advertise provision_tenant). A product-worker process that is up but was started with an incomplete CAUSELOOP_WORKER_JOB_TYPES env var fails readiness even though its heartbeat row exists — readiness is capability-gated, not just liveness-gated.2
object storage
object_store_readiness() (services/storage/object_store.py) constructs the configured provider (CAUSELOOP_OBJECT_PROVIDER: s3, azure, or test-only memory) and does a cheap connectivity check — head_bucket for S3/MinIO, get_container_properties for Azure Blob — without reading or writing an object.3
credential encryption
secret_encryption_readiness() (services/api/secrets.py) reports whether the configured CAUSELOOP_SECRET_PROVIDER (fernet, aws-kms, azure-key-vault) is actually usable: for fernet this means SOURCE_CREDS_KEY is set and the environment is not production (Fernet is local-only by policy — production must select KMS or Key Vault); for the cloud providers it means the corresponding key identifier env var is set.4
email delivery
email_delivery_readiness() (services/api/auth/mailbox.py) validates the email provider contract (CAUSELOOP_EMAIL_PROVIDER plus its required env vars) without sending anything — see Workers and jobs for how this same check gates whether product-worker will even claim send_email jobs.5
Redis
If
REDIS_URL is set, pings it directly. If not, readiness for this check is simply not redis_required — where redis_required defaults to true in production (CAUSELOOP_ENVIRONMENT=production) and false otherwise, overridable via CAUSELOOP_REDIS_REQUIRED./health/ready is called — nothing is cached — so a load balancer or orchestrator polling this endpoint gets a live, current answer, not a stale one computed at boot.
Authorization on mutating routes
Two independent dependencies gate a mutating request, and both are visible directly in each route’s decorator rather than hidden in middleware:- Role/session dependency — e.g.
_MUTATE = Depends(require_employee_role("onboarding_admin"))inonboarding_api.py. Asupport_readonlyemployee can call everyGETin that router but is rejected on every mutation; there is no separate read-only mode toggle, the role requirement is per-route. Depends(require_csrf)—services/api/auth/csrf.pyimplements double-submit-cookie CSRF protection: a random token is set as a readable (non-httpOnly) cookie at login, and the frontend proxy echoes it back as theX-CSRF-Tokenheader on every mutating request.require_csrfis a no-op forGET/HEAD/OPTIONSand otherwise does a constant-time (hmac.compare_digest) comparison of cookie versus header, raising403on any mismatch or absence. No server-side token storage is needed — a cross-site attacker’s form submission can make the browser send the cookie automatically, but same-origin JavaScript is required to read the cookie and set the matching header, which is exactly the property this defends.
POST/PUT/PATCH in onboarding_api.py: dependencies=[_MUTATE, Depends(require_csrf)]. Login and accept-invite routes are the deliberate exception — there is no session yet to protect, so they carry no CSRF dependency.
remediation_api.py is tenant-facing, not employee-facing, so its role dependency is different: mutations depend on Depends(require_permission("caps.write")) (CAP create/update) or Depends(require_permission("reviews.write")) (review update) against the authenticated TenantPrincipal’s permissions, each paired with Depends(require_csrf) the same way. There is no onboarding_admin employee-role check anywhere in this router.