|
| 1 | +# ADR 0031: the cloud control plane is a commercial `cloud/` dir; API-key auth, runs locally on SQLite |
| 2 | + |
| 3 | +- Status: accepted |
| 4 | +- Date: 2026-07-08 |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +M26 opens wave 4's paid tier: a hosted control plane (teams, workspaces, report history). ADR-0001 |
| 9 | +already fixed the open-core boundary (Apache-2.0 core + a separate commercial `cloud/`); the |
| 10 | +founder authorized building it. Two constraints from the bible stay binding: everything must run |
| 11 | +**locally with zero cloud accounts** (§11), and adding a paid dependency needs sign-off (§0). |
| 12 | + |
| 13 | +## Decision |
| 14 | + |
| 15 | +1. **Commercial code lives in `cloud/` with its own license.** `cloud/LICENSE` is a commercial |
| 16 | + license; `cloud/pyproject.toml` declares `LicenseRef-Volo-Commercial`. The OSS engine |
| 17 | + (`packages/`, `services/api`, `apps/web`, `integrations/`) stays Apache-2.0 and imports nothing |
| 18 | + from `cloud/`. `cloud/` is a workspace member so it builds and is tested, but it is a distinct |
| 19 | + licensing island. |
| 20 | +2. **No paid dependency; the stack is what's already here.** FastAPI + SQLModel + the existing |
| 21 | + `volo_core.storage` engine factory. It runs on **SQLite locally** (zero accounts) and Postgres |
| 22 | + in production purely via `VOLO_DB_URL` — no code change. Cloud tables are prefixed `cloud_*` so |
| 23 | + they coexist with the OSS schema on one engine. |
| 24 | +3. **Two-layer auth, both pluggable.** *Data* routes (read/ingest a workspace's reports) require a |
| 25 | + workspace **API key** via `X-Volo-Key`; the key is a `volo_sk_…` token stored only as a |
| 26 | + sha256 hash and returned exactly once at mint time, scoped to its workspace (cross-workspace → |
| 27 | + 403). *Management* routes (create team/workspace/key) reuse the OSS `require_principal` seam — |
| 28 | + open in local dev, and denied to anonymous callers when `VOLO_REQUIRE_AUTH=true`. That flag is |
| 29 | + the documented swap point for a real user-auth vendor (Clerk/Supabase JWT) in `auth.py`, which |
| 30 | + is a paid-dependency decision deferred to deployment, not baked into the code. |
| 31 | + |
| 32 | +## Consequences |
| 33 | + |
| 34 | +- The control plane is fully exercisable with `uv run` and SQLite — a contributor can develop and |
| 35 | + test the commercial tier with no accounts and no spend, keeping faith with §11. |
| 36 | +- API-key auth is real and sufficient for machine/CI access (the primary use); human SSO/RBAC is |
| 37 | + intentionally out of this MVP (that's M30) — management routes are a thin seam awaiting the |
| 38 | + vendor. |
| 39 | +- Storing only the key hash means a leaked database can't recover keys; the tradeoff is keys are |
| 40 | + unrecoverable and must be re-minted, which is the correct posture. |
| 41 | +- Because cloud tables share `SQLModel.metadata`, `init_schema` on any engine creates them too; |
| 42 | + harmless (unused tables) for OSS-only deployments, and the `cloud_*` prefix avoids collisions. |
| 43 | + |
| 44 | +## Alternatives considered |
| 45 | + |
| 46 | +- **A hosted GitHub-App-style service with a bundled auth vendor** — rejected for the MVP: forces |
| 47 | + a paid dependency and a specific vendor before there's a design partner; the pluggable seam + |
| 48 | + API keys deliver the workspace/history value now and let the vendor be chosen later. |
| 49 | +- **Put cloud code under `services/`** — rejected: it must be a clearly separate licensing island, |
| 50 | + not mixed with the Apache-2.0 `services/api`; a top-level `cloud/` with its own LICENSE makes the |
| 51 | + boundary unmistakable (à la Sentry/Phoenix). |
| 52 | +- **Encrypt keys instead of hashing** — rejected: hashing is simpler and strictly safer for an |
| 53 | + auth token (we never need the plaintext back); one-time display is the standard pattern. |
0 commit comments