# Multi-Tenant Design (Operational, Not Logical)

> Status: design. Target: ADR-0028.

## The model

We are **single-tenant per container**, with an **operationally
multi-tenant control plane** that orchestrates many single-tenant
deployments.

Why: tenant isolation is physical. One compromised container can't
read another tenant's volume. No request-multiplexed bugs (you can
fix the worst bug class by not having it).

Why not request-multiplexed multi-tenant: at the founder/team
segment, our tenant count is bounded by acquisition rate, not by
per-tenant compute cost. A 5-20 tenant first cohort fits comfortably
on a single VM; 100 tenants on a small cluster. We don't earn
multi-tenant complexity yet.

## Topology

```
                ┌────────────────────────────────────┐
                │  Control plane (Fly.io / Render)   │
                │  - Postgres: tenants, sessions     │
                │  - Stripe webhook receiver         │
                │  - Provisioner worker              │
                │  - Magic-link sender               │
                └─────────┬──────────────────────────┘
                          │  spawns + GC's
                          ▼
   ┌──────────────────────┴──────────────────────┐
   │  Tenant pool (per-tenant containers)        │
   │                                             │
   │  tenant_a:  caddy → fastify(127.0.0.1:3001) │
   │             volume: vol_tenant_a            │
   │  tenant_b:  caddy → fastify(127.0.0.1:3001) │
   │             volume: vol_tenant_b            │
   │  ...                                        │
   └─────────────────────────────────────────────┘
```

Edge router (Caddy on the control plane) maps
`<slug>.Lycato → tenant container` by tenant slug.

## Tenant lifecycle

1. **Provision** — Stripe checkout webhook → control plane writes
   `tenants` row + creates a new volume + starts a container with
   the `ghcr.io/lycato/cli:latest` image, env wired to volume path
   and a freshly-minted KMS DEK.
2. **Init** — container's first boot runs `lycato init --hosted`
   (a hosted-flavor of init that skips the wizard prompts and uses
   data from the Stripe payload).
3. **Active** — `lycato serve` runs inside the container. Edge routes
   the founder's domain.
4. **Suspend** — failed billing or founder-initiated. Container
   stops; volume preserved.
5. **Delete** — 30 days after suspend OR founder-initiated. Volume
   destroyed. Last surviving copy is founder-owned GitHub repo
   (always pushed on every commit, see `BACKUPS.md`).

## Tenant DB schema (control plane)

```sql
CREATE TABLE tenants (
  id             uuid PRIMARY KEY,
  slug           text UNIQUE NOT NULL,           -- e.g. "acesense"
  email          text NOT NULL,
  plan           text NOT NULL,                  -- founder | team
  status         text NOT NULL,                  -- active | suspended | deleting
  stripe_customer_id text,
  kms_key_id     text NOT NULL,                  -- per-tenant KEK
  volume_id      text NOT NULL,                  -- platform-specific
  github_repo    text,                           -- founder-owned mirror
  created_at     timestamptz NOT NULL,
  suspended_at   timestamptz,
  deleted_at     timestamptz
);
CREATE TABLE sessions (
  id          text PRIMARY KEY,                  -- opaque
  tenant_id   uuid NOT NULL REFERENCES tenants(id),
  user_email  text NOT NULL,
  role        text NOT NULL,                     -- owner | reviewer
  created_at  timestamptz NOT NULL,
  last_seen_at timestamptz NOT NULL,
  expires_at  timestamptz NOT NULL
);
CREATE TABLE audit_ops (
  id           uuid PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  actor        text NOT NULL,                    -- operator email or "system"
  action       text NOT NULL,                    -- provision | restart | restore | delete
  at           timestamptz NOT NULL,
  detail_json  jsonb
);
```

No tenant data ever lives in the control plane DB. The control
plane knows tenants exist and how to reach them; it never reads
their volume.

## Routing

Edge Caddy config (generated by control plane on tenant
provision/suspend):

```
<slug>.Lycato {
  encode zstd gzip
  reverse_proxy tenant-<id>.lycato.internal:3001
  @suspended `{tenant.status} == "suspended"`
  handle @suspended { redirect "/billing-failed" 302 }
}
```

## Scaling beyond the first 100 tenants

- 1-20 tenants: single VM, Docker compose, 4 vCPU, 16GB RAM.
- 20-100 tenants: Fly.io org with per-tenant Fly machines.
- 100-1000: introduce a tenant scheduler that suspends idle
  containers (hourly check + 24h idle threshold) and resumes on
  request (warm < 5s). Cuts compute cost by ~5x for active-monthly
  founder usage patterns.
- 1000+: re-evaluate request-multiplexed multi-tenant. At that
  point we have data to justify it.

## What's intentionally absent

- Per-region tenant placement. EU-only at launch; US later.
- Tenant migration tools. v1.x.
- Tenant snapshot rollback UI. Founder's own GitHub repo is the
  rollback today.
