Staging / Dev Environment Promotion Schema
On this page
Status: foundation implemented — data model, naming, and dispatch routing. Follow-ups: dual-instance provisioning, promotion orchestration (lerma + worker),
moltlazy releaseCLI.
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 becausedurable_objects.namespace_idis immutable at create time the other platform cannot rebind it — its container is cross-wired to the wrong DO namespace.Naming.container_application_name/1adds{platform_suffix}(andensure_container_application/5recreates a same-named app that is bound to the wrong namespace) to close this gap.
Data model
instances.environmentcolumn ("production"|"dev", default"production") with a unique index on(client_id, environment)— one instance per client per environment.- Client creation provisions the
productioninstance eagerly; thedevinstance is provisioned on first dev request (provision_staging/1follows the same pipeline with the-devnaming).
Dispatch routing
workers/fouria-dispatch parses both axes from the hostname label:
extractSlug— as before.- Platform: leading
staging-prefix → dev platform (staging namespace + staging Supabase). Unchanged semantics. - Environment: trailing
-devsuffix → clientdevenvironment; otherwiseproduction(parseEnvironmentSlug). - Worker name is derived directly from the routable slug:
tenant-{acme}ortenant-{acme-dev}(resolveTenant). - The Supabase tenant query filters the instance by
(base_slug, environment)so a frozen/stoppeddevinstance never blocks theproductioninstance and vice-versa. - Operational gate (
tenantRouteGate): production routes only whenclients.statusisactive/production;devroutes on the instance status instead (the client may still bedraftwhile its dev instance is provisioned lazily), rejectingprovisioning/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
-devroutable slug to exactly thedevinstance and a plain slug to exactly theproductioninstance — 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.environmentmigration + schema + unique index.Lerma.Instances.Naming+ tests.- Slug reservation (
staging-prefix,-devsuffix) + tests. - Dispatch two-axis parsing (
parseEnvironmentSlug, environment-awareresolveTenant, environment-filtered Supabase query) + tests. - Fouria
extractSlugFromHostnamenormalization + tests. - Moltlazy
PatchOptions.environment+ promotion manifest module + tests. - Root
tsconfig.jsonexcludes**/dist/**and**/node_modules/**fromtscso stale build artifacts no longer breakbun 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 releaseCLI assembling the release artifacts.