Skip to main content
Causeloop ships two systems out of one backend repository, and understanding the split is the single most important thing to internalize before writing any code:
  1. The deployed product — a small, allowlisted FastAPI app (services/api/product_app.py) plus the Next.js console (../frontend), backed by PostgreSQL, an S3/Blob object store, Redis, and two durable job workers. This is what runs in the product Docker Compose stack and the AWS/Azure cloud deployments. It is the only system covered by the rest of this Architecture section. (The current app.causeloop.ai/api.causeloop.ai Render+Vercel demo predates this split and still serves the legacy services/api/main.py app on its API side — see Render + Vercel demo.)
  2. The legacy research workbenchservices/api/main.py (a ~258-endpoint app) and the Vite SPA in apps/web. It hosts the historical MVP1 clustering/fishbone research pipeline, file-backed client onboarding, and process-global /mvp1 routes. It is not deployed, has no tenant isolation, and exists purely so the research pipeline that seeded Causeloop’s early causal-analysis work stays runnable locally. See Research overview for that system; it is out of scope everywhere else in this section.
app = create_product_app() in services/api/product_app.py is the only entry point the deployed container runs — the repo’s Dockerfile starts uvicorn services.api.product_app:app. services/api/main.py is never imported by that process. The two apps share some route modules and PostgreSQL models, but they are separate FastAPI applications with separate route surfaces.

Context diagram

Product vs. legacy, at a glance

Local product-mode ports (from infra/docker-compose.product.yml): frontend 13000, API 18000, Postgres 55433, Redis 16379, MinIO 19000/19001 (console), Mailpit 11025/18025 (SMTP/web UI). See Local product stack for how to bring the stack up and Deployment environments for the hosted topology.

The four durable authorities

Every piece of state the product needs to survive a restart lives in exactly one of four places. There is no fifth place — no in-memory cache that anything downstream depends on, no daemon thread holding state hostage in a request worker’s process.

PostgreSQL

The control schema holds tenants, employees, invitations, the durable job queue (control.platform_jobs), and worker heartbeats (control.runtime_heartbeats). Each tenant gets its own Postgres schema (tenant_template migration branch) with FORCE ROW LEVEL SECURITY on every table. See Tenancy and data model.

S3 / Azure Blob

Raw uploaded files (services/storage/object_store.py) are written once as immutable objects via bounded multipart upload with a SHA-256 digest computed in-stream. Locally this is MinIO; in AWS/Azure it is the same adapter interface against the real provider. Nothing keeps an uploaded file only in memory or only on local disk.

Redis

Used for exactly one thing: fixed-window login rate-limit counters (services/api/auth/rate_limit.py), keyed per-IP and per-account. If REDIS_URL isn’t set, the API falls back to an in-process counter — a documented, intentional local-dev-only degradation, never acceptable in production (CAUSELOOP_REDIS_REQUIRED=true is enforced there).

control.platform_jobs

The durable job queue itself is just more PostgreSQL — a FOR UPDATE SKIP LOCKED work table, not a separate broker. See Workers and jobs for the full retry/recovery model.

No daemon-thread work from API requests

The legacy workbench (services/api/clients_api.py, part of main.py’s surface) spawns threading.Thread(..., daemon=True) to run long work in the background. None of the product API’s routers do this. auth_api.py, onboarding_api.py, insights_api.py, members_api.py, metrics_api.py, remediation_api.py, and sources_api.py never start a thread to defer work past the response. Anything that takes real time — tenant provisioning, model training, insight materialization, email delivery — is inserted as a row into control.platform_jobs inside the same database transaction as the request that triggered it, and picked up by an independent worker process. A request that returns 202 Accepted with a job_id is telling the truth: the work is durably queued, not running on a thread that dies with the request’s process. This matters operationally: a daemon thread’s work is lost if the API process restarts mid-request; a queued job survives any restart of the API, the worker, or both, because its state is a database row, not a stack frame. See Workers and jobs for how retry and recovery build on that guarantee.