> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# System overview

> The deployed Causeloop product boundary versus the legacy research workbench kept for local experimentation, and the durable authorities that back both.

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](/deployment/render-vercel-demo).)
2. **The legacy research workbench** — `services/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](/research/overview) for that system; it is out of scope everywhere else in this section.

<Note>
  `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.
</Note>

## Context diagram

```mermaid theme={null}
flowchart TB
    customer(["Tenant user\n(client analyst/admin)"])
    staff(["Onboarding staff\n(Causeloop employee)"])

    subgraph product["Causeloop product boundary"]
        frontend["Next.js console\nTenant console + staff Onboarding Portal.\nBrowser never calls the API directly."]
        api["Product API\nservices/api/product_app.py\n44 allowlisted operations + health"]
        productWorker["product-worker\nmaterialize_insights, send_email,\ntrain_tenant_model"]
        provisionerWorker["provisioner-worker\nprovision_tenant.\nOwner DB creds; no Redis/email/object-write."]
    end

    postgres[("PostgreSQL\ncontrol schema + one schema per tenant.\nSource of truth for all state.")]
    objectStore[("S3 / Azure Blob\nImmutable raw uploads (MinIO locally).\nSource of truth for uploaded files.")]
    redis[("Redis\nLogin rate-limit counters only.")]
    email(["SES / Azure Communication /\nlocal SMTP"])

    customer -- HTTPS --> frontend
    staff -- HTTPS --> frontend
    frontend -- "server-side proxy route,\ncookie session forwarded" --> api
    api -- "app_rw role,\nper-request transaction" --> postgres
    api -- "bounded multipart\nupload/read" --> objectStore
    api -- "INCR/EXPIRE\nrate-limit keys" --> redis
    productWorker -- "app_rw role: claims\ncontrol.platform_jobs" --> postgres
    provisionerWorker -- "owner role: CREATE SCHEMA,\nGRANT, tenant_template migrations" --> postgres
    provisionerWorker -. "readiness check only\n(HEAD bucket), never writes" .-> objectStore
    productWorker -- "decrypted at\nsend time only" --> email
```

## Product vs. legacy, at a glance

|                  | Deployed product                                                                               | Legacy research workbench                  |
| ---------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ |
| API entry point  | `services/api/product_app.py` (`create_product_app()`)                                         | `services/api/main.py`                     |
| Frontend         | Next.js console (`../frontend`), server-side proxy at `src/app/api/backend/[...path]/route.ts` | Vite/React SPA (`apps/web`)                |
| Route surface    | 44 allowlisted operations                                                                      | \~258 endpoints, unrestricted              |
| Tenant isolation | Per-tenant Postgres schema + `FORCE ROW LEVEL SECURITY`                                        | Process-global state, no tenant isolation  |
| Background work  | `control.platform_jobs` durable queue, two dedicated worker processes                          | In-process `threading.Thread(daemon=True)` |
| Where it runs    | Local product Compose stack; AWS/Azure cloud deployments (`deploy/aws`, `deploy/azure`)        | Local only — never deployed                |

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](/onboarding/local-product-stack) for how to bring the stack up and [Deployment environments](/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.

<CardGroup cols={2}>
  <Card title="PostgreSQL" icon="database">
    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](/architecture/tenancy-and-data-model).
  </Card>

  <Card title="S3 / Azure Blob" icon="box-archive">
    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.
  </Card>

  <Card title="Redis" icon="bolt">
    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).
  </Card>

  <Card title="control.platform_jobs" icon="list-check">
    The durable job queue itself is just more PostgreSQL — a `FOR UPDATE SKIP LOCKED` work table, not a separate broker. See [Workers and jobs](/architecture/workers-and-jobs) for the full retry/recovery model.
  </Card>
</CardGroup>

## 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](/architecture/workers-and-jobs) for how retry and recovery build on that guarantee.

## Related

* [Backend in depth](/architecture/backend)
* [Workers and jobs](/architecture/workers-and-jobs)
* [Backend repo tour](/onboarding/backend-repo-tour)
* [Local product stack](/onboarding/local-product-stack)
