# FourIA Cloud Architecture

> **Last updated:** Jul 2026
> **Platform:** Cloudflare Workers for Platforms (WFP)
> **Database:** Supabase PostgreSQL — PostgREST from the Dispatch Worker, Ecto from the Dashboard (docker-compose for local dev)

---

## Overview

FourIA uses Cloudflare Workers for Platforms (WFP) to provision isolated, per-customer gateways. Each customer receives their own **User Worker** (deployed as `tenant-{slug}` in the dispatch namespace) which manages an OpenClaw gateway inside a Sandbox container, with independent bindings (R2, Durable Objects, Analytics Engine).

All tenant traffic enters through a single **Dispatch Worker** bound to the wildcard domain `*.fouria.io`. The Dispatch Worker extracts the tenant slug from the subdomain, resolves it against the shared Supabase PostgreSQL database (with a KV cache layer), resolves the user's role, and dispatches to the appropriate User Worker with role/billing context in headers. No subdomain routing is used in the URL path — each tenant gets its own subdomain.

The **FourIA Dashboard** runs as a standalone Phoenix app inside a Cloudflare Container at `dash.fouria.io` (on the fouria.io zone, with a bypass worker route — no worker assigned), backed by the shared Supabase PostgreSQL database. A separate dev instance runs at `dev-dash.fouria.io`. The Dashboard is a pure control panel — it has no direct dependency on fouria or the Dispatch Worker. Platform data (clients, partners, users, instances, billing) is managed exclusively through the Dashboard.

**Architecture principle:** lerma and fouria are independent services that share only a data store. No direct HTTP dependencies exist between them.

**DNS zones:** Tenant traffic is served via `*.fouria.io` (fouria.io zone). The Dashboard is served via `dash.fouria.io` and `dev-dash.fouria.io` on the fouria.io zone through bypass worker routes — these are **not** handled by `*.fouria.io`.

```
TENANT ROUTING — fouria.io zone
────────────────────────────────
*.fouria.io
  │
  ├─ {tenant}.fouria.io ──── (tenant traffic)
  │    │
  │    ├─ 1. Dispatch Worker (workers/fouria-dispatch/)
  │    │      │  slug:     extract tenant slug from hostname
  │    │      │  cache:    check KV (TENANT_INDEX, 60s TTL)
  │    │      │  db:       fallback query to Supabase (PostgREST)
  │    │      │  auth:     Cloudflare Access JWT (aud resolved per tenant)
  │    │      │  user:     resolve user role from client_users
  │    │      │  headers:  forward X-FourIA-Role, X-FourIA-Metadata
  │    │      │  limits:   plan-level CPU & subrequest limits
  │    │      │
  │    │      └─ DISPATCHER.get("tenant-{slug}")
  │    │           └─ User Worker: tenant-{slug}
  │    │                ├─ Sandbox container (OpenClaw gateway)
  │    │                ├─ BACKUP_BUCKET (R2: per-tenant)
  │    │                ├─ ANALYTICS (AE: per-tenant metrics)
  │    │                └─ Secrets (API keys, gateway token)
  │    │
  │    └─ ... (unlimited tenants in fouria-tenants namespace)

DASHBOARD — fouria.io zone (bypass worker routes)
────────────────────────────────
dash.fouria.io ──── (production, internal management)
  │
  └─ FourIA Dashboard (apps/lerma/)
         │  runtime: Phoenix app in Cloudflare Container
         │  backend: Supabase PostgreSQL (via Ecto/postgrex)
         │  db:      source of truth for clients, instances, billing
         │  api:     internal REST API (/api/clients, /api/instances, etc.)
         │  ops:     CF API client for WFP provisioning
         │  domains: dash.fouria.io, dev-dash.fouria.io

dev-dash.fouria.io ──── (development/staging dashboard)

── Shared Platform Services ──

  ├─ AI Gateway (moltworker)
  │    → Unified AI provider routing (shared across tenants)
  │    → Native PII filtering — replaces the deprecated Outbound Worker (fouria-outbound)
  │
  └─ Supabase PostgreSQL (shared, managed)
       → Source of truth: clients, partners, users, instances, plans, billing
       → Accessed via Ecto (Dashboard) and PostgREST (Dispatch Worker)
```

### Component Connectivity

End-to-end component map — the control plane (lerma) and the data plane (Dispatch → Tenant → Sandbox) share the same Supabase PostgreSQL source of truth and have no direct HTTP dependency on each other:

```text
                    ┌──────────────────────────────────────────────────────────┐
   CONTROL PLANE                       Supabase (PostgreSQL)                          DATA PLANE
                              clients · credit_pools · client_users ·
                               instances · billing · source of truth
                    └─────────▲─────────────────────────────────────┬──────────┘
                              │                                     │
      Ecto / postgrex         │       PostgREST (SUPABASE_URL       │
        (Dashboard)           │         + SERVICE_ROLE_KEY)         │
                              │                                     │
                  ┌───────────────────────┐         ┌────────────────────────────────┐
                  │   FourIA Dashboard    │         │        Dispatch Worker         │
                  │     (apps/lerma)      │         │    workers/fouria-dispatch     │
                  │    dash.fouria.io     │         │ *.fouria.io · KV · Access JWT  │
                  │  Phoenix + LiveView   │         │        role resolution         │
                  │     control plane     │         └───────────────┼────────────────┘
                  └───────────┼───────────┘                         │ DISPATCHER.get()
                              │ CF API (R2 buckets,                 │ "tenant-{slug}"
                              │ secrets, dispatch ns)               │ + X-FourIA-Role
                              ▼                                     │ + X-FourIA-Metadata
                           Cloudflare APIs                          ▼
                             (provisioning)         ┌────────────────────────────────┐
                                                    │         Tenant Worker          │
                                                    │         (apps/fouria)          │
                                                    └───────────────┼────────────────┘
                                                                    │ sandbox container
                                                                    ▼
                                                    ┌────────────────────────────────┐
                                                    │       Sandbox Container        │
                                                    │       · OpenClaw Gateway       │
                                                    │        · moltlazy patch        │
                                                    │      · cf-unified-billing      │
                                                    └───────────────┼────────────────┘
                                                                    │ provider calls
                                                                    ▼
                                                    ┌────────────────────────────────┐
                                                    │     Cloudflare AI Gateway      │
                                                    │       (moltworker) · PII       │
                                                    └───────────────┼────────────────┘
                                                                    ▼
                                                              Upstream LLMs
```

Shared platform services: Cloudflare R2 (per-tenant backups + release bundles), Analytics Engine (`fourai_cost_metrics`), and the shared Cloudflare AI Gateway (`moltworker`).

**Reserved hosts:** The Dispatch Worker at `*.fouria.io` reserves the following hostnames (they are handled specially and are not valid tenant slugs):

| Reserved hostname    | Purpose                        | Zone                     |
| -------------------- | ------------------------------ | ------------------------ |
| `dash.fouria.io`     | FourIA Dashboard (production)  | fouria.io (bypass route) |
| `dev-dash.fouria.io` | FourIA Dashboard (development) | fouria.io (bypass route) |

---

## Project Structure

```
workers/
├── fouria-dispatch/          # Dispatch Worker
│   ├── src/index.ts          # Wildcard dispatch, Supabase lookup, KV cache, cron fan-out
│   ├── src/auth.ts           # Cloudflare Access JWT verification
│   ├── src/supabase.ts       # PostgREST tenant + user-role resolution
│   ├── wrangler.jsonc        # DISPATCHER, TENANT_INDEX (KV), SUPABASE_* secrets
│   ├── package.json
│   └── tsconfig.json

apps/
├── lerma/         # FourIA Dashboard (Elixir/Phoenix, internal only)
│   ├── lib/lerma/
│   │   ├── repo.ex               # Ecto repo → Supabase PostgreSQL
│   │   ├── cloudflare/           # CF Workers API client (provisioning)
│   │   ├── provisioning/         # TenantProvisioner orchestrator
│   │   ├── clients/              # Client/user CRUD
│   │   ├── instances/            # Instance lifecycle
│   │   ├── billing/              # Billing context
│   │   └── dashboard/            # Dashboard accounts context
│   ├── lib/lerma_web/
│   │   ├── router.ex             # Routes (browser + API)
│   │   ├── plugs/cf_access_auth.ex # Cloudflare Access auth plug
│   │   └── live/                 # LiveView pages
│   ├── Dockerfile                # Container image (Phoenix app)
│   └── priv/repo/migrations/     # Supabase schema (Ecto migrations)
│
└── fouria/         # User Worker template
    ├── src/index.ts          # OpenClaw gateway proxy, auth, metrics
    ├── src/auth/              # JWT verification, RBAC
    ├── src/gateway/           # Sandbox container management
    ├── src/routes/            # API routes, admin UI, OTel forwarding
    ├── wrangler.jsonc         # 3 environments: local, staging, production
    ├── Dockerfile             # OpenClaw container image
    └── ...
```

---

## Key Components

### 1. Dispatch Worker (`workers/fouria-dispatch/`)

The entry point for all platform tenant traffic at `*.fouria.io`. Extracts the tenant slug from the subdomain, resolves it via KV cache (60s TTL) with Supabase (PostgREST) fallback, validates Cloudflare Access JWT per-tenant, and dispatches to the correct User Worker.

> **Note:** The Dispatch Worker only handles tenant routing on the `*.fouria.io` zone. Dashboard traffic at `dash.fouria.io` and `dev-dash.fouria.io` is served on the fouria.io zone via bypass worker routes — the Dispatch Worker does not intercept these hostnames.

**Request Flow:**

1. Request arrives at `{tenant}.fouria.io`
2. Dispatch Worker extracts tenant slug from hostname (e.g., `tenant-arua.fouria.io` → `tenant-arua`)
3. Checks KV cache (`TENANT_INDEX`) keyed by `slug:{tenant}` (60s TTL)
4. On cache miss, queries Supabase via PostgREST (service-role key):
   - `clients` — `id`, `slug`, `billing_plan`, `status`
   - plus `credit_pools` (credits used/max), validated `client_users` (seat count), and `instances` (instance id/status)
5. Requires a valid Cloudflare Access JWT; production validates that the token's `aud` resolves to the requesting tenant
6. Dispatches to User Worker (`tenant-{slug}`) via `DISPATCHER.get()` with plan-level CPU/subrequest limits
7. Forwards `X-FourIA-Role` and `X-FourIA-Metadata` headers to the User Worker

**Responsibilities:**

- Extract tenant slug from wildcard subdomain hostname
- KV cache (TENANT_INDEX, 60s TTL) for tenant routing — warm reads, Supabase fallback
- Direct Supabase queries (PostgREST via `SUPABASE_URL` + service-role key) — no dashboard HTTP API dependency
- Per-tenant Cloudflare Access JWT verification (token `aud` resolved to the tenant)
- Apply per-plan CPU and subrequest limits
- Fan-out cron triggers to all active tenants every minute
- Invalidate tenant cache via internal endpoint (`/_invalidate/:slug`)

**Bindings:**

| Name                        | Type                 | Purpose                                   |
| --------------------------- | -------------------- | ----------------------------------------- |
| `DISPATCHER`                | `dispatch_namespace` | `fouria-tenants` namespace                |
| `SUPABASE_URL`              | `secret_text`        | Supabase project URL (PostgREST endpoint) |
| `SUPABASE_SERVICE_ROLE_KEY` | `secret_text`        | Service-role key (bypasses RLS for reads) |
| `TENANT_INDEX`              | `kv_namespace`       | Tenant slug → entry cache (60s TTL)       |
| `PLATFORM_DOMAIN`           | `plain_text`         | `fouria.io`                               |

> Staging uses `STAGING_SUPABASE_URL` / `STAGING_SUPABASE_SERVICE_ROLE_KEY` when present, falling back to the production credentials.

**Auth model:** Per-instance. Every tenant environment hostname has its own Cloudflare Access application and group (`{slug}.fouria.io` for production, `{slug}-dev.fouria.io` for dev). In production the token's `aud` must resolve to the requested hostname — Paso4 ROOT operators are exempt (they may hold the platform wildcard `*.fouria.io` AUD or the shared `Paso4 Root Users` group) — and Dispatch resolves a client role only when the user is `validated` and linked to that exact `instances.id`. Staging continues to require the Paso4 platform audience (ROOT).

**Routing gate:** Before authentication, Dispatch checks the resolved tenant's operational state (`tenantRouteGate`). The `production` environment is gated on `clients.status` (`active`/`production`); the `dev` environment is gated on the **instance** status instead, so a running dev instance behind a still-`draft` client is routable.

**Invitation flow:** Lerma stores new client users as `invited` with only a SHA-256 token hash and a seven-day expiry. The emailed link lets the recipient accept or reject the invitation. Invitation emails are delivered by a supervised background task (`Lerma.Invitations`) after the user row is committed, so a slow or failing SMTP relay never holds a database connection or blocks the dashboard; the delivery outcome is broadcast over PubSub and surfaced as a toast plus a per-user badge. Acceptance records a structured Cloudflare Access identity reference, changes the user to `validated`, and syncs their email only into the groups for accepted instance assignments. `invited` and `rejected` users are excluded from both Access groups and Dispatch role resolution. Existing Access applications are reconciled to a group-only allow policy during provisioning and user synchronization; `everyone` is never retained.

### 2. User Workers (`apps/fouria/`)

Each customer gets their own User Worker deployed to the `fouria-tenants` dispatch namespace. These are instantiated from the same codebase but with per-customer bindings.

User Workers receive tenant context and user identity from the Dispatch Worker via HTTP headers (`X-FourIA-Role`, `X-FourIA-Metadata`). They never access the platform database directly — all user/tenant resolution is handled by the Dispatch Worker before the request reaches the User Worker.

**Per-Customer Bindings (provisioned by FourIA Dashboard):**

| Binding                         | Type                       | Example                                            |
| ------------------------------- | -------------------------- | -------------------------------------------------- |
| `Sandbox`                       | `durable_object_namespace` | Per-tenant Sandbox class                           |
| `BACKUP_BUCKET`                 | `r2_bucket`                | `fouria-backup-arua`                               |
| `ANALYTICS`                     | `analytics_engine`         | `fouria_metrics_arua`                              |
| `BROWSER`                       | `browser`                  | Shared remote browser                              |
| `MOLTBOT_GATEWAY_TOKEN`         | `secret_text`              | Auto-generated per tenant                          |
| `CLOUDFLARE_AI_GATEWAY_API_KEY` | `secret_text`              | Platform AI credential from Lerma                  |
| `CLOUDFLARE_API_TOKEN`          | `secret_text`              | REST transport alias of the platform AI credential |
| `CF_AI_GATEWAY_GATEWAY_ID`      | `plain_text`               | Shared: `"moltworker"`                             |
| `CF_ACCESS_AUD`                 | `plain_text`               | Per-tenant AUD tag                                 |

All platform data access goes through the Dispatch Worker (Supabase PostgREST) and Dashboard (Supabase + Ecto). User Workers never access the platform database directly — they receive billing context via `X-FourIA-Metadata` and role via `X-FourIA-Role`, both set by the Dispatch Worker.

### 3. FourIA Dashboard (`apps/lerma/`)

Internal management application for Paso4 managers only. Runs as a standalone Phoenix app inside a Cloudflare Container (Durable Object `PHOENIX_CONTAINER`) at `dash.fouria.io` (on the fouria.io zone, with a bypass worker route). A separate dev instance is served at `dev-dash.fouria.io`.

The Dashboard is a **pure control panel** — it has no direct dependency on fouria or the Dispatch Worker. It manages platform data (clients, partners, users, instances, billing) through its Supabase backend and interacts with Cloudflare APIs for provisioning.

**Database architecture:**

| Environment | Backend           | Database                          |
| ----------- | ----------------- | --------------------------------- |
| dev/test    | Ecto (PostgreSQL) | `DATABASE_URL` (Docker compose)   |
| production  | Ecto (PostgreSQL) | `DATABASE_URL` (managed Supabase) |

In production, the Dashboard connects to Supabase PostgreSQL via `postgrex` using the `DATABASE_URL` environment variable (direct PostgreSQL wire protocol).

**Communication model:** The Dashboard communicates with Supabase PostgreSQL via Ecto — no public HTTP API is needed for platform data. Cloudflare API calls (WFP provisioning, R2 management, secrets) use a CF API client with admin-level credentials.

**Authorization model:** Two role levels control API access:

| Role             | Scope          | Capabilities                                      |
| ---------------- | -------------- | ------------------------------------------------- |
| **PASO4 root**   | Global         | Full CRUD on all entities, create Partner roots   |
| **Partner root** | Partner-scoped | CRUD limited to associated clients and their data |

---

#### Users

Management of FourIA user entities (logical users linked to a Cloudflare Zero Trust Access identity and role).

```
GET    /user              → List all users (paginated)
GET    /user/:user_id     → Get single user details
```

_PASO4 root & Partner root (scoped):_

```
POST   /user              → Create a user entity (linked to Zero Trust Access ID + role)
```

---

#### Partners

A partner entity represents an organization that can own multiple clients, each with one instance of the service.

```
GET    /partner/:partner_id/style  → Get partner custom branding (views, logos)
PUT    /partner/:partner_id/style  → Update partner custom branding
```

_PASO4 root only:_

```
GET    /partner            → List all partners (paginated)
GET    /partner/:id        → Get partner details
POST   /partner            → Create a new partner
PUT    /partner/:id        → Update partner
DELETE /partner/:id        → Delete partner
```

---

#### Clients

Client entities belong to a partner and each has one associated instance. A client can also be internal (no partner association).

_PASO4 root & Partner root (scoped):_

```
GET    /client                          → List all clients (paginated, scoped by role)
GET    /client/:client_id               → Get client details from Supabase
POST   /client/new                      → Create a new client
PUT    /client/update                   → Update client information
```

_PASO4 root only:_

```
GET    /client/:client_id/partner       → Get associated partner (null if internal client)
```

---

#### Instances

Each client has exactly one instance (User Worker deployed via WFP dispatch namespace).

_PASO4 root & Partner root (scoped):_

```
POST   /client/instance/create                              → Create a new instance via WFP binding
                                                                    (limited to 1 per client)
PUT    /client/instance/:instance_id/worker-plan/:plan_type  → Manually change instance worker plan
GET    /client/instance/:instance_id/health                  → Instance health status
```

**Health endpoint** returns instance observability data via Worker bindings:

| Check               | Source                                | Description                                                     |
| ------------------- | ------------------------------------- | --------------------------------------------------------------- |
| Gateway status      | Sandbox DO / process probe            | Whether the OpenClaw gateway process is running (up/down)       |
| Container heartbeat | Analytics Engine (`container-active`) | Most recent heartbeat timestamp                                 |
| OTel health         | _(to be defined)_                     | Additional health signals exposed via OpenTelemetry integration |

The OTel integration will be extended to surface further instance state metrics (memory pressure, CPU throttle, R2 mount status, WebSocket connectivity). These signals will be defined during OTEL implementation.

---

#### Client Users

Manages email-verified invitations and instance-scoped Cloudflare Zero Trust Access assignments. User responses include `status`, `validated_at`, `cf_access_identity`, and `instance_ids`.

_PASO4 root & Partner root (scoped):_

```
GET    /api/clients/:client_id/users         → List invited, validated, and rejected users
POST   /api/clients/:client_id/users         → Create an invitation for selected instance_ids (email sent asynchronously)
PUT    /api/clients/:client_id/users/:id     → Update name or role
DELETE /api/clients/:client_id/users/:id     → Remove a user and revoke assigned access
```

_Invite recipient (token-authenticated):_

```
GET    /invitations/:token                   → Review and accept or reject an invitation
```

---

#### Analytics

Cost and usage analytics drawn from Cloudflare Analytics Engine. See [cost-observability.md](/llms/platform/apps/fouria/cost-observability/index.md) for the full event schema and example queries.

_PASO4 root & Partner root (scoped):_

```
GET    /analytics/global                    → Executive summary dashboard
                                                 PASO4 root: segmented by partners
                                                 Partner root: filtered to partner's clients only
```

Analytics Engine dataset: `fourai_cost_metrics` — written by User Workers via the `ANALYTICS` binding. The dashboard fetches aggregated queries from this dataset to render cost breakdowns, container uptime, R2 bandwidth, and WebSocket session metrics.

---

**Provisioning Flow (for a new customer):**

```
1. POST /partner → Create Partner (if not internal)
2. PUT  /partner/:id/style → Configure partner branding
3. POST /client/new → Create Client (slug, plan, partner_id)
4. POST /client/instance/create → Provision User Worker via WFP:
   a. Create R2: "fouria-backup-{slug}" (via CF API)
   b. Auto-generate gateway token
   c. Deploy User Worker to dispatch namespace (via WFP API)
      → multipart upload: worker bundle + binding metadata
   d. Set secrets (via WFP secrets API): MOLTBOT_GATEWAY_TOKEN, CDP_SECRET, R2 keys, etc.
   e. Set tags: customer_id:{id}, plan:{plan}, env:production
   f. Set CF_ACCESS_AUD: Cloudflare Access audience tag (per-tenant JWT auth)
5. POST /user → Create user entities for Zero Trust Access identities
6. POST /client/:client_id/user/:user_id → Link users to client with roles
7. Invalidate dispatch cache: GET /_invalidate/{slug} on Dispatch Worker
```

---

## Environments

### Environment Matrix

| Name           | Domain      | Database                  | Dispatch Namespace       | AI Gateway            |
| -------------- | ----------- | ------------------------- | ------------------------ | --------------------- |
| **local**      | `localhost` | Supabase (docker-compose) | N/A (test pool)          | Direct API keys       |
| **staging**    | `fouria.io` | Supabase (managed)        | `fouria-tenants-staging` | Shared (`moltworker`) |
| **production** | `fouria.io` | Supabase (managed)        | `fouria-tenants`         | Shared (`moltworker`) |

### Staging vs Production

Staging shares the AI Gateway with production (same gateway ID) but has its own Supabase database and dispatch namespace. This allows feature testing with real provider models without risk of touching production data.

**Secret Strategy:**

- **Staging**: Reuses production AI Gateway key (shared), separate R2 keys
- **Production**: Full secrets per customer via WFP secrets API

---

## AI Gateway Reuse (Gateway Pool)

Cloudflare limits AI Gateways to **10 (Free) / 20 (Paid) per account**. To avoid exhausting this quota with one gateway per tenant environment, lerma manages a **reusable AI Gateway pool** (DB table `ai_gateways`, dashboard at `/dashboard/ai-gateways`, API under `/api/ai-gateways`).

### How selection works

At provisioning time (`TenantProvisioner` → `:ai_gateway` step → `Lerma.AiGateways.resolve/3`):

1. **Explicit override** — an operator-provided `ai_gateway_id` / `ai_gateway_pool_id` (or the binding of an existing error-state instance being re-provisioned) wins.
2. **Least-loaded pooled gateway** — the enabled pool entry whose `environment` matches (or is unrestricted) and that still has capacity (`used < max_instances`), sorted by fewest bound instances.
3. **Dedicated fallback** — if no pool entry has capacity and `AI_GATEWAY_CREATE_DEDICATED=true` (the default), a dedicated per-tenant gateway `fouria-{slug}[-dev]` is created and owned by the tenant (deleted on rollback/deprovisioning). When `false`, provisioning fails with `no_ai_gateway_capacity` instead.

Shared pool gateways are never deleted by the provisioner — the tenant only stores `ai_gateway_id` + `ai_gateway_pool_id` on its instance record and binds its worker to that gateway.

### Manual distribution control

Each pool entry has a **`max_instances`** ceiling — the maximum number of tenant instances that may bind it. An operator edits `max_instances` per gateway (dashboard edit form or `PUT /api/ai-gateways/:id`) to steer where new tenants land based on usage, e.g. route high-usage customers onto a dedicated-capacity gateway and everyone else onto a shared pool.

Tenants can be rebalanced at any time by reassigning an instance to another gateway (`POST /api/instances/:id/assign-gateway`, or the "Change AI Gateway" action on `/dashboard/instances/:id`). This re-deploys the worker so its `CF_AI_GATEWAY_GATEWAY_ID` binding points at the new gateway.

### What it creates per tenant

| Resource                                        | Dedicated (legacy)                       | Pooled (reuse)                      |
| ----------------------------------------------- | ---------------------------------------- | ----------------------------------- |
| Cloudflare AI Gateway                           | 1 per environment, `fouria-{slug}[-dev]` | 0 — reuses an existing pool gateway |
| `ai_gateways` row                               | 0                                        | 1 (shared across many tenants)      |
| Instance `ai_gateway_id` / `ai_gateway_pool_id` | `ai_gateway_id` set                      | both set                            |

---

## Routing & DNS

### DNS Configuration

```
# Tenant routing — fouria.io zone
Type: A
Name: *.fouria.io
Target: Dispatch Worker (Cloudflare-managed)
Proxied: Yes

# Dashboard — fouria.io zone (bypass worker routes — no worker assigned)
Type: CNAME (or Worker route)
Name: dash.fouria.io
Target: FourIA Dashboard (Cloudflare-managed)
Proxied: Yes

Type: CNAME (or Worker route)
Name: dev-dash.fouria.io
Target: FourIA Dashboard dev (Cloudflare-managed)
Proxied: Yes
```

### Subdomain-Based Routing (Dispatch Worker)

The Dispatch Worker is bound to the wildcard `*.fouria.io` route. The tenant is identified by the subdomain, not by the URL path:

| Subdomain            | Purpose                                                  | Auth                                           |
| -------------------- | -------------------------------------------------------- | ---------------------------------------------- |
| `{tenant}.fouria.io` | Tenant traffic → User Worker                             | Cloudflare Access JWT (aud resolved to tenant) |
| `dash.fouria.io`     | Internal management → Dashboard (fouria.io zone, bypass) | Cloudflare Access JWT (PASO4/Partner root)     |
| `dev-dash.fouria.io` | Dev dashboard (fouria.io zone, bypass)                   | Cloudflare Access JWT (dev audience)           |

The Dispatch Worker only handles subdomains of `*.fouria.io`. Dashboard hostnames on fouria.io are bypassed — they are **not** dispatched by the Dispatch Worker via wildcard matching.

```typescript
// Dispatch Worker routing pattern (tenant traffic only)
const slug = extractSlug(hostname, PLATFORM_DOMAIN);
// tenant-a.fouria.io → "tenant-a"

// Step: resolveTenant(slug) → DISPATCHER.get(workerName).fetch()
```

### Supabase Lookup + KV Cache

```
Request flow:
  Browser → {tenant}.fouria.io
  → Dispatch Worker extracts slug from hostname
  → Check KV cache (TENANT_INDEX, key: slug:{tenant})
    ├─ HIT (60s TTL) → dispatch with cached worker name + plan limits
    └─ MISS → query Supabase via PostgREST (service-role key):
         clients:      id, slug, billing_plan, status
         credit_pools: lifetime_consumed, lifetime_purchased
         client_users: seat count for the client
         instances:    status (frozen/stopped guard)
       → Resolve user role (production, non-platform aud):
         client_users: role WHERE client_id = ? AND email = ?
       → Cache result in KV (60s TTL)
       → Forward X-FourIA-Role + X-FourIA-Metadata headers
       → DISPATCHER.get(tenant-{slug}) → User Worker
```

---

## Data Architecture

### Shared Supabase PostgreSQL Database

Both the Dispatch Worker and the Dashboard access the same Supabase PostgreSQL database. The Dashboard owns the schema (Ecto migrations), while the Dispatch Worker reads from it at runtime via the PostgREST API (`SUPABASE_URL` + service-role key).

**Tables used by Dispatch Worker (read-only):**

| Table                   | Purpose                                                               |
| ----------------------- | --------------------------------------------------------------------- |
| `clients`               | `id`, `slug`, `billing_plan`, `status` — tenant identity + plan       |
| `credit_pools`          | `lifetime_consumed`, `lifetime_purchased` — credit usage for dispatch |
| `client_users`          | Validated seat count + RBAC role (`ADMIN`, `BASE_USER`)               |
| `client_user_instances` | Requires the user to be assigned to the requested instance            |
| `instances`             | `id`, `status` — scope users and reject frozen/stopped instances      |

**Tables used by Dashboard (read/write):**

| Table                      | Purpose                                                 |
| -------------------------- | ------------------------------------------------------- |
| `clients`                  | Tenant organizations                                    |
| `client_users`             | Tenant invitations, verified identity, status, and role |
| `client_user_instances`    | Accepted user-to-instance access assignments            |
| `instances`                | Cloudflare Worker containers per tenant                 |
| `dashboard_users`          | Platform administrators (ROOT role)                     |
| `client_subdivisions`      | Per-tenant agent namespaces                             |
| `client_user_subdivisions` | User-to-subdivision membership                          |
| `invoices`                 | Billing periods per tenant                              |
| `invoice_line_items`       | Individual billing line items                           |
| `analytics_events`         | OTLP-style analytics event storage                      |

All inter-service data access goes through Supabase (PostgREST for the Dispatch Worker, Ecto for the Dashboard) plus a KV cache layer. The FourIA platform API is not exposed publicly — tenant routing is resolved by the Dispatch Worker, metrics are written directly to Analytics Engine, and the Dashboard reads/writes platform data through its Supabase backend.

### User Resolution Flow

User Workers receive role and billing context from the Dispatch Worker via HTTP headers:

| Header              | Source          | Purpose                                                           |
| ------------------- | --------------- | ----------------------------------------------------------------- |
| `X-FourIA-Role`     | Dispatch Worker | User's role within the tenant (ROOT, ADMIN, BASE_USER)            |
| `X-FourIA-Metadata` | Dispatch Worker | base64-encoded plan/billing metadata (plan, seats, credits, etc.) |

The Dispatch Worker resolves user identity by querying `client_users` and `dashboard_users` tables during request routing. User Workers read these headers in their `resolveCustomerRole` middleware — they never make external API calls for user resolution.

---

## PII Filtering (Cloudflare AI Gateway)

PII filtering for all AI provider traffic is handled natively by Cloudflare AI Gateway. No separate outbound worker or Presidio container is required.

```
User Worker → fetch() → AI Gateway (native PII filtering) → AI provider → response returned
```

The previously planned Outbound Worker + Presidio pipeline has been replaced by Cloudflare AI Gateway's built-in PII filtering capabilities.

---

## OTel & Metrics Flow

All telemetry is written directly to Cloudflare Analytics Engine via the `ANALYTICS` binding — no HTTP API involved:

```
OpenClaw Gateway (container) → generates OTel metrics
  → POST /api/otel/v1/metrics (User Worker receives)
  → User Worker parses and accepts metrics (status: 200)
  → Metrics are written to ANALYTICS.writeDataPoint() for AI queries, container activity, etc.
```

The User Worker's `/api/otel/v1/metrics` endpoint accepts JSON OTLP payloads and acknowledges receipt. Actual cost/usage metrics are tracked independently through separate event types in the `ANALYTICS` binding (see `src/metrics.ts`).

---

## Security Model

| Layer                  | Mechanism                                                                    |
| ---------------------- | ---------------------------------------------------------------------------- |
| **DNS**                | Cloudflare proxied (orange cloud)                                            |
| **Auth**               | Cloudflare Zero Trust Access — per-tenant JWT (aud resolved to tenant)       |
| **Routing**            | Tenant isolation via WFP dispatch namespace                                  |
| **Data**               | Per-customer R2 buckets (no cross-tenant access)                             |
| **Platform**           | Supabase PostgreSQL — service-role PostgREST (Dispatch) + Ecto (Dashboard)   |
| **Dashboard**          | Supabase (shared) — internal HTTP API, not publicly exposed                  |
| **Egress**             | Cloudflare AI Gateway — native PII filtering, only approved hosts            |
| **PII**                | Cloudflare AI Gateway native PII filtering (replaces Presidio anonymization) |
| **Secrets**            | WFP secrets API (no secrets stored in dashboard DB)                          |
| **Cache invalidation** | Internal endpoint `/_invalidate/:slug` (bearer-token protected)              |

---

## Plan Limits (infrastructure-level, not client billing)

> **Note:** These are internal infrastructure limits mapped to Cloudflare resource allocations per deployment. They are NOT client-facing billing tiers. v1.0-beta uses a single flat-fee plan with credit-based billing. Tiered infrastructure limits exist in code for future multi-plan support.

| Tier       | CPU (ms) | Subrequests | Container   | R2 Storage |
| ---------- | -------- | ----------- | ----------- | ---------- |
| Free       | 10,000   | 10          | lite        | 2 GB       |
| Basic      | 30,000   | 50          | standard-1  | 8 GB       |
| Pro        | 50,000   | 100         | standard-2  | 12 GB      |
| Enterprise | 90,000   | 1,000       | standard-3+ | Custom     |

---

## Glossary

| Term                          | Definition                                                                      |
| ----------------------------- | ------------------------------------------------------------------------------- |
| **WFP**                       | Cloudflare Workers for Platforms                                                |
| **Dispatch Namespace**        | Container holding all User Workers                                              |
| **User Worker**               | Per-customer Worker (`tenant-{slug}`)                                           |
| **Dispatch Worker**           | Routing + auth layer (`fouria-dispatch`)                                        |
| **Outbound Worker**           | (Deprecated) Egress + PII layer (`fouria-outbound`) — replaced by CF AI Gateway |
| **Partner**                   | Organization owning multiple clients                                            |
| **Client**                    | A customer with one instance of the service                                     |
| **Instance**                  | A deployed User Worker + Sandbox container                                      |
| **PASO4 root**                | Global platform admin role                                                      |
| **Partner root**              | Partner-scoped admin role                                                       |
| **Presidio**                  | (Deprecated) Microsoft PII detection/anonymization service                      |
| **FourIA Dashboard**          | Internal management app (Phoenix in Cloudflare Container)                       |
| **KV Cache (TENANT_INDEX)**   | KV namespace caching tenant slug → entry (60s TTL)                              |
| **Supabase**                  | Managed PostgreSQL + PostgREST API — shared source of truth                     |
| **SUPABASE_URL**              | Secret — Supabase project URL used by the Dispatch Worker (PostgREST)           |
| **SUPABASE_SERVICE_ROLE_KEY** | Secret — service-role key that bypasses RLS for tenant lookup                   |
| **ANALYTICS**                 | Analytics Engine binding (`fourai_cost_metrics`)                                |
