# Staging / Dev Environment Promotion Schema

> Status: **foundation implemented** — data model, naming, and dispatch routing.
> Follow-ups: dual-instance provisioning, promotion orchestration (lerma +
> worker), `moltlazy release` CLI.

## Motivation

Fouria provisions a sandboxed OpenClaw instance per client. Historically,
"staging" meant the clients provisioned by the **lerma dev deployment** — a
separate lerma with its own Supabase, dispatch namespace (`fouria-tenants-staging`)
and `staging-` hostname prefix. That made the whole dev platform the staging
environment for every customer.

This document changes the model: **each client owns its own environments**.
Every client gets a `production` instance (provisioned eagerly) and a `dev`
instance (provisioned lazily) for developing agents, tools, and configuration
without touching production. Lerma dev remains a separate deployment, but it is
no longer "the staging environment" — it just follows the same schema for its
own clients.

Promotion moves the _immutable_ configuration (agent workspace, tools, and
config files) from the client's `dev` instance to its `production` instance
using the Cloudflare Sandbox squashfs backup API with `.gitignore`-style
exclusions — a git-repo-style deployment.

## Two orthogonal axes

| Axis                                  | Values                                                            | Encoded by                                                                                          |
| ------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Platform** (the lerma deployment)   | `production` (fouria.io), `staging`/dev (dev.fouria.io dashboard) | `staging-` hostname **prefix**, `-staging` bucket/gateway **suffix**, dispatch namespace + Supabase |
| **Client environment** (the instance) | `production`, `dev`                                               | `-dev` hostname / worker-name **suffix**                                                            |

The `staging-` prefix continues to select the dev platform (unchanged). The
`-dev` suffix is new and selects the client's `dev` environment. Base slugs are
reserved against both (`staging-` prefix and `-dev` suffix) so they never
collide with the two axes.

## Naming

For a client with base slug `acme`:

| Platform   | Env        | Hostname                     | Worker name       | Container app                     | Namespace                | R2 bucket                        |
| ---------- | ---------- | ---------------------------- | ----------------- | --------------------------------- | ------------------------ | -------------------------------- |
| production | production | `acme.fouria.io`             | `tenant-acme`     | `tenant-acme-sandbox`             | `fouria-tenants`         | `fouria-backup-acme`             |
| production | dev        | `acme-dev.fouria.io`         | `tenant-acme-dev` | `tenant-acme-dev-sandbox`         | `fouria-tenants`         | `fouria-backup-acme-dev`         |
| staging    | production | `staging-acme.fouria.io`     | `tenant-acme`     | `tenant-acme-sandbox-staging`     | `fouria-tenants-staging` | `fouria-backup-acme-staging`     |
| staging    | dev        | `staging-acme-dev.fouria.io` | `tenant-acme-dev` | `tenant-acme-dev-sandbox-staging` | `fouria-tenants-staging` | `fouria-backup-acme-dev-staging` |

Naming is deterministic and centralized in `Lerma.Instances.Naming`
(`apps/lerma/lib/lerma/instances/naming.ex`).

> **Why the container application carries the platform suffix.** Worker scripts
> are isolated by their dispatch namespace and Durable Object namespaces are
> created inside that namespace, but **container applications live at the
> Cloudflare account level**. Without the platform axis a client with the same
> slug in both landscapes resolves to the same `tenant-{slug}-sandbox`;
> whichever platform creates it first wins, and because
> `durable_objects.namespace_id` is immutable at create time the other platform
> cannot rebind it — its container is cross-wired to the wrong DO namespace.
> `Naming.container_application_name/1` adds `{platform_suffix}` (and
> `ensure_container_application/5` recreates a same-named app that is bound to
> the wrong namespace) to close this gap.

## Data model

- `instances.environment` column (`"production"` | `"dev"`, default
  `"production"`) with a **unique index on `(client_id, environment)`** — one
  instance per client per environment.
- Client creation provisions the `production` instance eagerly; the `dev`
  instance is provisioned on first dev request (`provision_staging/1` follows
  the same pipeline with the `-dev` naming).

## Dispatch routing

`workers/fouria-dispatch` parses both axes from the hostname label:

1. `extractSlug` — as before.
2. **Platform:** leading `staging-` prefix → dev platform (staging namespace +
   staging Supabase). Unchanged semantics.
3. **Environment:** trailing `-dev` suffix → client `dev` environment;
   otherwise `production` (`parseEnvironmentSlug`).
4. Worker name is derived directly from the routable slug:
   `tenant-{acme}` or `tenant-{acme-dev}` (`resolveTenant`).
5. The Supabase tenant query filters the instance by
   `(base_slug, environment)` so a frozen/stopped `dev` instance never blocks
   the `production` instance and vice-versa.
6. **Operational gate** (`tenantRouteGate`): production routes only when
   `clients.status` is `active`/`production`; `dev` routes on the **instance**
   status instead (the client may still be `draft` while its dev instance is
   provisioned lazily), rejecting `provisioning`/`frozen`/`stopped`/missing
   instances.

The fouria worker's `extractSlugFromHostname` (`src/auth/routing.ts`) strips
the `staging-` prefix but preserves the `-dev` suffix so metrics and the R2
secrets namespace stay consistent per environment.

## Promotion (squashfs + gitignore)

Moltlazy owns the immutable config generation, so it also owns the promotion
manifest (`packages/moltlazy/src/promotion/index.ts`):

- **Promotable:** `openclaw.json`, `moltlazy-session.json`, `moltlazy-tools.json`, `moltlazy-logging.json`, `workspace/` (agents, tools,
  memory, skills).
- **Excluded (never promoted):** `state/`, `credentials/`, `agents/`,
  `sessions/`, `secrets/`, keys, `.env`.

Promotion copies the two release artifacts (a `createBackup({
useGitignore: true })` squashfs of the workspace plus the versioned config
objects) from the client's dev bucket to its production bucket and records a
`release-pointer.json`, then triggers the production worker to restore (copy-
on-write FUSE overlay) and re-run `moltlazy patch`. Each environment uses a
distinct `BACKUP_ENCRYPTION_KEY`, so the copy decrypts with the dev key and
re-encrypts with the production key.

## Isolation guarantees

- One instance per (client, environment): distinct worker script, Sandbox DO,
  container application, R2 bucket, AI gateway, and encryption key.
- Dispatch resolves a `-dev` routable slug to exactly the `dev` instance and a
  plain slug to exactly the `production` instance — never cross-environment.
- A dev instance's frozen/stopped status never affects the production route.
- Promotion only ever carries the immutable manifest; runtime state,
  credentials, and sessions never cross the environment boundary.

## Implementation status

Done:

- `instances.environment` migration + schema + unique index.
- `Lerma.Instances.Naming` + tests.
- Slug reservation (`staging-` prefix, `-dev` suffix) + tests.
- Dispatch two-axis parsing (`parseEnvironmentSlug`, environment-aware
  `resolveTenant`, environment-filtered Supabase query) + tests.
- Fouria `extractSlugFromHostname` normalization + tests.
- Moltlazy `PatchOptions.environment` + promotion manifest module + tests.
- Root `tsconfig.json` excludes `**/dist/**` and `**/node_modules/**` from
  `tsc` so stale build artifacts no longer break `bun run typecheck`.

Follow-ups:

- Dual-instance provisioning in `TenantProvisioner` (prod eager / dev lazy).
- Access Policy per environment (dev Access application for `acme-dev.fouria.io`).
- Worker admin promote endpoint (`POST /api/admin/promote`) + `/_control/:slug/promote`.
- Lerma control-plane promotion orchestration + UI button.
- `moltlazy release` CLI assembling the release artifacts.
