Status: Accepted
Date: 2026-04-19
Deciders: ObjectStack Protocol Architects
Supersedes: The v3.4/v4.0 "per-organization database" tenant model
Consumers: @objectstack/service-tenant, @objectstack/spec/cloud, future service-subscription, service-quota, service-audit-log, service-dlp-policy, service-solution-history
The v3.4 / v4.0 multi-tenant model in @objectstack/service-tenant provisions one physical database per organization, registered in sys_tenant_database. Logical separation between environments (dev / test / prod / sandbox) is achieved by an env_id column carried on every row in every data-plane table.
Operating this model in production surfaced five classes of recurring problems:
- Leaky logical isolation. Every query must carry
WHERE env_id = ?. A single missing predicate in a hand-written query, a migration, a background job, or a badly-written skill can corrupt production from a developer shell. - Coupled schema evolution. A Solution can't upgrade its schema in
devwithout affectingprod— the tables are the same physical tables. This blocks blue/green schema rollouts, destructive migrations, and safe rollback. - Complex backup / DR. Backing up or restoring just
prodrequires per-row filtering during dump/restore. Point-in-time recovery of one environment leaks into others. - Difficult Solution publishing. "Promote Solution X from dev to prod" degenerates into row-level copy jobs with
env_idrewriting — slow, fragile, and nearly impossible to make atomic. - No physical boundary for security / compliance. Per-environment encryption keys, IP allow-lists, retention policies, and audit isolation all require a per-environment DB to be credible.
Meanwhile, the ecosystem has moved on:
- Turso / libSQL, Neon, Supabase branches, PlanetScale branches, and Cloudflare D1 all make "a database per environment" a near-free operation (milliseconds to provision, cents per month to idle).
- Power Platform, Salesforce, and ServiceNow all expose environments as first-class primitives backed by isolated storage.
- Kubernetes namespaces are the pattern developers reach for; the data layer should match.
We upgrade the multi-tenant architecture from per-organization database to per-environment database, with a hard split between Control Plane and Data Plane:
Registers environments and how to reach them — never stores business data:
| Table | Purpose |
|---|---|
sys_environment |
One row per environment — (organization_id, slug) UNIQUE. Includes physical DB addressing fields (database_url, database_driver, storage_limit_mb, provisioned_at). |
sys_database_credential |
Rotatable encrypted secrets (N:1 with sys_environment) |
sys_environment_member |
Per-environment RBAC ((environment_id, user_id) UNIQUE) |
sys_package_installation |
Which packages are installed in which environment (includes environment_id) |
sys_metadata |
Schema metadata for all environments (includes organization_id + env_id) |
Design note:
sys_environment_databasehas been merged intosys_environment. Physical DB addressing is a 1:1 relationship — a dedicated table was unnecessary. This reduces joins and simplifies the provisioning flow.
Design note:
sys_metadataandsys_package_installationlive in the Control Plane, not in environment DBs. Metadata is environment-scoped viaenv_id(NULL = platform-global). This follows the Power Apps model where the management plane tracks all schema and packages.
Each environment owns its own physical database containing:
- All business objects —
account,contact, user tables, … - Zero system tables or
environment_idcolumns. The environment is implicit in the connection.
better-auth sessions carry a single active_environment_id. The tenant router resolves:
session.active_environment_id
→ sys_environment (url, driver, region — single row lookup)
→ sys_database_credential (active secret, decrypted)
→ data-plane driver
Switching environments ⇒ swapping DB connections. There is no in-process filter that can be forgotten.
EnvironmentProvisioningService (new) exposes:
provisionOrganization(req)— atomically creates the org's default environment and its physical DB (replacesprovisionTenant).provisionEnvironment(req)— allocates any subsequentdev/test/sandbox/previewenvironment, each with its own DB and credential row. DB addressing fields are written directly onto thesys_environmentrow.rotateCredential(environmentId, plaintext)— issues a newactivecredential and revokes the previous one.
Physical-DB allocation is delegated to pluggable EnvironmentDatabaseAdapter implementations (initially turso; libsql / sqlite / postgres drop in without core changes).
- v4.x keeps
sys_tenant_databaseregistered as a deprecation shim (TSDoc@deprecated, runtime log warning). The new control-plane objects ship alongside it, additive, non-breaking. migrations/v4-to-v5-env-migration.tsships in v4.x as a skeleton (stable public API) and is executed during the v5.0 upgrade.- v5.0 removes
sys_tenant_databaseand its reader code entirely.
The migration is non-destructive and idempotent: each legacy org's database is reused as its new prod environment DB — no data movement, no cutover window.
- Hard isolation. Prod and dev are separate databases; no
WHEREclause can be forgotten. - Independent schema evolution. Solutions upgrade their schema in
dev, validate, then promote via a single DB-level backup/restore intoprod. - Trivial backup / DR. Per-environment backup = native DB backup. PITR stays within one environment.
- First-class Solution publishing. "Publish" becomes a schema + metadata export from
devand an idempotent apply intoprod, operating on cleanly-scoped DBs. - Per-environment security posture. Each environment owns its own credential, its own network ACL, its own quotas, its own retention.
- Pluggable backend. Driver-agnostic — new backends register an
EnvironmentDatabaseAdapterwithout core changes. - Future-proof. Naturally slots in quotas (
sys_quota), subscriptions (sys_subscription), audit (sys_audit_log), DLP (sys_dlp_policy), and solution history (sys_solution_history) as subsequent PRs.
- More databases to operate. Every org now has ≥1 DB; heavy users of
sandbox/previewenvironments may have 5–20. Mitigated by Turso/libSQL free-tier economics and lazy provisioning. - Cross-environment reporting (e.g. "how many leads across all of Acme's envs?") becomes an explicit federation query. Acceptable — such queries are rare and better expressed at the BI layer.
- Cold starts. A dormant environment may need to be resumed on first access. Mitigated by the router's TTL cache and the adapter's warm-up hook.
- Connection sprawl. A node handling many environments holds N connections. Mitigated by an LRU connection pool with per-env TTL (already present in the v3.4 router).
- Irrevocable breaking change at v5.0. v4.x ships the shim and migration; v5.0 removes legacy code. Customers must run the migration before upgrading.
- No change to Zod-first,
.describe()on every field,sys_prefix invariants. - No change to the public
ObjectKernel/ plugin lifecycle. - No change to
better-authsession shape beyond renamingactive_organization_id→active_environment_id(v5.0).
- Stay with per-org DB +
env_idcolumn. Rejected — the failure modes above are structural, not implementation bugs. - Schema-per-environment inside one DB. Works for Postgres but not Turso/libSQL/SQLite, and defeats the backup/DR argument. Rejected.
- Row-level security via Postgres RLS. Strengthens the
env_idapproach but still leaves schema evolution coupled and DR complex. Rejected. - One global DB + tenant column. Was never on the table — already discarded in v3.4's ADR-0001.
packages/spec/src/cloud/environment.zod.ts— protocol schemaspackages/services/service-tenant/src/objects/sys-environment.object.ts— merged control-plane environment object (includes DB addressing)packages/services/service-tenant/src/environment-provisioning.ts— provisioning servicepackages/services/service-tenant/migrations/v4-to-v5-env-migration.ts— migration skeleton- Power Platform environments: https://learn.microsoft.com/power-platform/admin/environments-overview
- Salesforce sandboxes: https://help.salesforce.com/s/articleView?id=data.sandboxes.htm
- Turso multi-DB pricing: https://turso.tech/pricing