Skip to content

Latest commit

 

History

History
133 lines (85 loc) · 10.1 KB

File metadata and controls

133 lines (85 loc) · 10.1 KB

ADR-0004: Cloud Control Plane + Per-Project Kernels

v5.0 update (2026): Throughout this document, the term project has been renamed to environment (no aliases; CLI flags, URL paths, schemas, env vars all hard-renamed). See ADR-0006 for the rationale and .changeset/v5-project-to-environment-rename.md for the breaking-change list. The body below is preserved verbatim for historical context.

Status: Superseded (2026-04-23) — the physical split between apps/cloud and apps/objectos was reversed. The control-plane and data-plane plugin groups now live in a single unified apps/objectos process, differentiated by the plugin pack loaded at boot (createControlPlanePlugins() + multi-project bootstrap). All other primitives — KernelManager, per-project kernels, sys_project.hostname routing, DefaultProjectKernelFactory — remain unchanged.

Date: 2026-04-22 Deciders: ObjectStack Protocol Architects Builds on: ADR-0002 (environment-per-database), ADR-0003 (package as first-class citizen) Consumers: apps/objectos, @objectstack/runtime, @objectstack/service-tenant, Studio project management UI


Context

After ADR-0002 and ADR-0003 landed, a single apps/objectos process owned three concerns simultaneously:

  1. The control plane: sys_project, sys_database_credential, sys_project_member, billing, package registry.
  2. The data plane: every project's ObjectQL traffic (/api/v1/data/*, /api/v1/meta/*, /api/v1/ui/*, /api/v1/ai/*).
  3. The tenant routing layer: hostname/header/session → project → driver resolution.

All projects shared a single ObjectKernel. Multi-tenancy was achieved by injecting a different dataDriver on each request via HttpDispatcher.resolveEnvironmentContext(). This worked for early dogfooding but broke down in three ways:

  1. Plugin isolation is per-kernel, not per-request. Projects that need different AppPlugins, different feature flags, or different ServicePlugin versions cannot coexist in one kernel. Every request has to funnel through the same schema registry and event bus.
  2. Control-plane and data-plane lifecycles collide. Rotating a project credential, pausing a project, or marking a tenant failed should never require restarting the control plane. With one process, they do.
  3. Horizontal scaling is coarse-grained. Self-hosted users who run a single DB and no control plane still pay the cost of every sys-plugin (tenant, package, billing scaffolds). Cloud operators, conversely, want control-plane traffic and data-plane traffic scaled independently.

The Supabase/Power Platform topology solves this by splitting concerns physically:

Concern Supabase Power Platform ObjectStack (this ADR)
Project registry Supabase platform Dataverse admin apps/cloud
Per-project runtime Postgres + PostgREST per project Environment per org ObjectKernel per project
Hostname routing <ref>.supabase.co <env>.crm.dynamics.com <slug>.objectstack.app

We want the same separation without forcing every deployment into the cloud topology — the self-hosted single-binary workflow must remain a first-class target.


Decision

1. Hard split between Control Plane and Data Plane

Two deployable applications:

App Role Owns Kernel model
apps/cloud Control plane sys_project, sys_database_credential, sys_project_member, sys_package*, auth, billing Single shared kernel
apps/objectos Data plane (dual-mode) /api/v1/data/*, /api/v1/meta/*, /api/v1/ui/*, /api/v1/ai/* Single kernel or per-project kernels

apps/cloud always runs as a single kernel — the control plane has one tenant (the platform itself).

apps/objectos runs in one of two modes, selected at boot by OS_RUNTIME_MODE:

  • self-hosted (default) — behaves exactly like pre-ADR-0004. One kernel, full plugin set from objectstack.config.ts, datasource mapped via env vars. No control plane dependency.
  • cloud — hostname → project routing. A KernelManager lazily boots a dedicated ObjectKernel per project, pulling project/credential/package metadata from apps/cloud via OS_CONTROL_PLANE_URL.

2. KernelManager + ProjectKernelFactory (new in @objectstack/runtime)

  • KernelManager (packages/runtime/src/kernel-manager.ts): LRU+TTL cache of Map<projectId, CachedKernel>. Exposes getOrCreate(projectId) (concurrent-safe, single-flight per id) and evict(projectId) (calls kernel.shutdown()). Configurable maxSize and ttlMs.
  • DefaultProjectKernelFactory (packages/runtime/src/project-kernel-factory.ts): given a projectId, reads project + credential + package-install rows from the control-plane driver, clones the base stack config, overrides the default datasource mapping to point at the project's driver, instantiates an AppPlugin per installed bundle, and calls kernel.bootstrap().

Both are exported from @objectstack/runtime. Self-hosted mode never imports KernelManager.

3. HttpDispatcher is kernel-aware

HttpDispatcher.dispatch() now resolves a kernel before routing:

  • If kernelManager is wired, resolve projectId (hostname → sys_project.hostname, then URL prefix, then X-Project-Id, then session), then kernelManager.getOrCreate(projectId). Use that kernel's RestServer / HttpServer for the request.
  • Otherwise, fall back to the shared kernel path — identical to pre-ADR-0004 behavior.

Control-plane routes (/api/v1/cloud/*) stay on apps/cloud's kernel in both topologies.

4. Hostname as routing primitive

sys_project.hostname is the canonical routing key:

  • Auto-assigned at provisioning time as <org-slug>-<short-id>.objectstack.app (default template; see ProjectProvisioningService).
  • Editable by org admins via POST /api/v1/cloud/projects/:id/hostname, with uniqueness enforced at the service layer (returns 409 Conflict) and format validation (returns 400 Bad Request).
  • Cache invalidation is explicit: updating a hostname calls EnvironmentDriverRegistry.invalidate(projectId) so the next request does a fresh lookup.

Custom domains and multi-hostname binding (ACME certificates, sys_domain table) are intentionally out of scope for this ADR.

5. Studio surfaces hostname as a first-class field


Consequences

Positive

  • Plugin and metadata isolation is physical. A misbehaving project's AppPlugin cannot corrupt another project's schema registry or event bus.
  • Self-hosted stays simple. A single OS_DATABASE_URL env var is still enough to run a full ObjectStack backend; no sys plugins are required.
  • Control plane and data plane scale independently. apps/cloud can run on a single node (low traffic, heavy consistency); apps/objectos in cloud mode can scale horizontally behind hostname-based load balancing.
  • Credential rotation never restarts the control plane. Rotating sys_database_credential just invalidates the affected project's cached kernel; everything else keeps running.
  • Hostname routing is observable. Studio shows the bound hostname; operators can verify routing with a single curl -H 'Host: …'.

Negative / Trade-offs

  • First request to a cold project pays the kernel bootstrap cost (typically 50–200 ms; dominated by driver handshake). Mitigated by LRU caching and optional warm-up hooks.
  • Memory scales with active-project count. A 512 MB container comfortably holds ~100 active kernels with the default plugin set; operators must tune KernelManager.maxSize for their workload.
  • Two deploy targets to track. CI now publishes apps/cloud and apps/objectos separately; self-hosters who don't need a control plane can ignore apps/cloud but must still pin to a matching version for cloud-mode upgrades.

Migration

  • Existing self-hosted deployments keep working unchanged. OS_RUNTIME_MODE defaults to self-hosted; no env var changes required.
  • Pre-ADR-0004 cloud-style deployments (single apps/objectos with sys plugins) should split into apps/cloud + apps/objectos cloud-mode by Phase 2 of the rollout. A one-shot script that migrates existing sys_project rows out of the shared DB is bundled under packages/services/service-tenant/migrations/.

Not in scope

  • Custom-domain TLS and ACME automation. The hostname field accepts any valid hostname, but TLS termination remains the operator's responsibility (usually via the fronting CDN / ingress).
  • Cross-project kernel sharing / multi-tenant plugin pools. Every project gets its own kernel in cloud mode; we do not reuse kernels across projects even when they share the same AppPlugin set.
  • Full sys_domain table for N:1 hostname→project. MVP supports one hostname per project; multi-hostname binding is a future ADR.

References

  • Plan: apps-server-sleepy-newell (the plan this ADR crystallizes).
  • Code: packages/runtime/src/kernel-manager.ts, packages/runtime/src/project-kernel-factory.ts, packages/runtime/src/http-dispatcher.ts, apps/objectos/server/bootstrap.ts, apps/cloud/objectstack.config.ts.
  • Related guide: Cloud vs Self-Hosted deployment.