Skip to content

Latest commit

 

History

History
225 lines (181 loc) · 10.2 KB

File metadata and controls

225 lines (181 loc) · 10.2 KB

ADR-0006: Environment & Project as Independent Siblings — v3

Status: Superseded by v4 (0006-project-environment-split.v4.md) — 2026-05-20 Date: 2026-05-20 (v3) Deciders: ObjectStack Protocol Architects Supersedes: v1 (strict tree), v2 (siblings + sys_deployment join) Superseded by: v4 — drops the dev-workspace Project concept entirely; user code unifies on sys_package / sys_package_version / sys_package_installation. Builds on: ADR-0002 (Environment-Per-Database Isolation), ADR-0003 (Package as First-Class Citizen) Consumers: @objectstack/service-tenant, @objectstack/service-cloud, @objectstack/spec/cloud, apps/cloud, the Console cloud_control app

v3 revision note — v2 introduced Project and Environment as siblings joined by a sys_deployment M:N table. That join was modelled on Vercel's git-driven deployment graph, which ObjectStack does not need: every Project-to-Environment promotion goes through a published sys_package_version, which already carries the snapshot. v3 deletes sys_deployment and connects Project ↔ Environment only via the package version (publish → install). This matches Power Platform (Solution → Environment) and Salesforce (Unlocked Package → Org) exactly, with one fewer table.

v3 also adopts the drop-schema-clean path: the system is pre-launch, the user accepted a one-shot wipe of sys_project* / sys_package* rows. No grandfathering code is shipped.


Context

After v2 we hit two practical problems:

  1. Phase 0 was a misleading UI relabel. sys_project.label was changed to "Environment", but the schema still conflated authoring and runtime concerns. Reviewers correctly observed "this is just a rename — you didn't actually split anything." Reverted in commit 0f412a0f.
  2. sys_deployment solves a problem we don't have. v2 envisioned a M:N between Project Revisions and Environments to support per-env version pinning. But ADR-0003 already says published package versions ARE the pinnable unit. A Project publishes a sys_package_version; an Environment installs it via sys_package_installation. There is no third table needed.

Meanwhile, the platform is pre-launch and all production data can be wiped. This is a one-time window: structural splits done now cost ~3-5 days; done post-launch they cost weeks plus a dual-write migration window plus a token- invalidation event for SSO clients.


Decision

Adopt the independent-siblings model with no join table. Project and Environment are both first-class children of Organization, and they communicate only through published Package Versions (Marketplace contract).

Organization (account root — billing, members, SSO realm)
  ├── Environment (1..N — runtime container)
  │     ├── hostname, database_url, plan, quota, status
  │     └── installed_packages (M:N → sys_package_version)
  │
  └── Project (0..N — optional dev workspace)
        ├── Branch (main / staging / feature/*)
        │     └── Revision (immutable snapshot per publish)
        │           ↓ publish
        │       sys_package_version  ──→  install into any Environment
        │
        └── Member (developer RBAC, separate from env operator RBAC)

No FK between Project and Environment. The published Package Version is the only artifact that crosses the boundary.

Two personas, two paths

Persona First action Default Console nav
Consumer Register → Create Environment → Install from Marketplace Environments, Marketplace, Members, Billing
Builder Either path A or B below + Developer group (Projects, Branches, Revisions, Published Packages)
A: registers, creates Environment, then clicks "Customize this Environment" → auto-creates a Project initialized from the env's current metadata
B: registers, creates Project directly, builds metadata, publishes Package Version, installs into one or many Environments

Projects are zero-or-many under an Org. Consumer accounts may have zero Projects forever — that is the supported case, not a degenerate edge.

Schema

Renamed / repurposed

  • sys_project becomes pure Dev Workspace: id, organization_id, slug, display_name, description, repo_url?, default_branch_id?, visibility, archived_at?, created_at, updated_at, created_by. No hostname / database / quota / plan / kernel state.

New

  • sys_environment: id, organization_id, slug, display_name, hostname (unique global), database_url / database_driver / database_owner (per ADR-0002), plan, storage_limit_bytes, status, is_default, last_published_at, archived_at?, created_at, updated_at, created_by.

Re-pointed FKs

  • sys_project_branch.project_idsys_project (already the case conceptually; now actually correct semantically — was misleadingly pointing at the runtime row before v3).
  • sys_project_member.project_idsys_project (dev RBAC).
  • sys_environment_member is a new table (env operator RBAC), peer of sys_project_member.
  • sys_package_installation.environment_idsys_environment (was project_idsys_project).
  • sys_oauth_application.environment_idsys_environment (SSO per env, not per project).
  • sys_quota_usage.environment_id, sys_billing_period.environment_id (usage accrues at env; billing rolls up by org).
  • sys_package_version.published_from_project_idoptional lookup (soft attribution; not a hard FK enforced at install time).

No deployment / promotion join table. "Promote staging → prod" is:

INSERT INTO sys_package_installation (environment_id, package_version_id)
SELECT 'prod-env', package_version_id
FROM sys_package_installation
WHERE environment_id = 'staging-env';

Hostname routing

The Cloudflare Worker resolves <slug>.objectos.appsys_environment.id (was sys_project.id). The cache key in multi-project-plugin.ts becomes hostname → environmentId. The DO singleton hydrates per Environment.

Console UX

Default (Consumer) nav:

Environments
Marketplace
Members
Billing

Developer mode (toggled by clicking "Customize this Environment" or "Create Project") adds:

Developer
├── Projects
├── Branches
├── Revisions
└── Published Packages

The toggle is sticky per user; once on, it stays on across sessions.


Consequences

Positive

  1. Consumer first-run is unambiguous. No hidden Project rows, no auto- created "default project" with a fake slug. Account → Env → Install.
  2. Schema is small and obvious. 1 new table (sys_environment) + field rename on installations. No join table.
  3. Per-env version pinning is trivial. sys_package_installation already carries package_version_id; different envs can pin different versions the same package without any new mechanism.
  4. SSO isolation is automatic. Each Environment is its own IdP realm with its own OAuth client; prod tokens cannot be replayed against staging.
  5. Billing rollup is straightforward. Usage rows carry environment_id; SUM(usage) GROUP BY organization_id produces the invoice.
  6. Promotion = SQL. No special "deployment" verb in the API — it's POST /cloud/environments/:prod/installations with the same package_version_id the staging env uses.

Negative / Costs

  1. One-shot data wipe. Pre-launch is the only time this is free. After launch, the same split costs ~3 weeks (dual-write, backfill, token rotation).
  2. @objectstack/cloud API surface change. POST /cloud/projects is replaced by POST /cloud/environments; POST /cloud/projects re-emerges later (different semantics) when Builder UX ships. SDK consumers (none in production yet) must rename calls.
  3. apps/cloud/server/index.ts and worker.ts change their hostname resolution from sys_project to sys_environment.

Neutral

  1. sys_project_branch keeps its name and semantics — was always a dev concept, just had a confused FK target before v3.
  2. sys_package_version.published_from_project_id is optional, so platform-seeded starter packages (no Project owner) work the same way.

Phasing (post v3 acceptance)

Phase Scope Notes
1 — Drop & redefine schema Wipe prod sys_project*, sys_package*, sys_quota_usage, sys_billing_period; rebuild sys_project as Dev Workspace; ship new sys_environment + member table One commit; one prod DB wipe
2 — Cloud routes refactor POST /cloud/environments (provisioning); POST /cloud/environments/:id/installations; hostname→env in worker; remove old /cloud/projects runtime routes Same PR as Phase 1
3 — Console nav refactor Default nav = Environments + Marketplace; Developer toggle for Projects; install action targets envs Same PR or follow-up
4 — Deploy + smoke Build → deploy → wipe Neon → register fresh account → create env → install starter One go-live moment
5 (later) — Builder UX Project create, branch list, revision publisher, "Customize this Environment" flow, Publish to Marketplace Separate epic

Phases 1-4 are the 3-5 day budget.


Open questions

  1. Multiple Environments per account in free tier? Recommend: 1 free env per Org; paid plans get N. Enforced at provision time.
  2. Default Project on Customize flow? When a Consumer clicks "Customize this Environment", do we create a Project named after the env (acme-crm) or prompt for a name? Recommend: auto-name from env slug, allow rename.
  3. What about packages installed into an Environment that originate from a Project that gets archived? Installations are immutable references to sys_package_version; archiving the Project does not break running Environments. Republishing requires un-archiving.

References

  • ADR-0002 — Environment-Per-Database Isolation
  • ADR-0003 — Package as First-Class Citizen
  • v2 (archived): 0006-project-environment-split.v2.md
  • Power Platform: Solution → Environment model
  • Salesforce: Unlocked Package → Org model