F FourIA GitHub ↗

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 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

AxisValuesEncoded 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:

PlatformEnvHostnameWorker nameContainer appNamespaceR2 bucket
productionproductionacme.fouria.iotenant-acmetenant-acme-sandboxfouria-tenantsfouria-backup-acme
productiondevacme-dev.fouria.iotenant-acme-devtenant-acme-dev-sandboxfouria-tenantsfouria-backup-acme-dev
stagingproductionstaging-acme.fouria.iotenant-acmetenant-acme-sandbox-stagingfouria-tenants-stagingfouria-backup-acme-staging
stagingdevstaging-acme-dev.fouria.iotenant-acme-devtenant-acme-dev-sandbox-stagingfouria-tenants-stagingfouria-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.