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_deploymentM: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 publishedsys_package_version, which already carries the snapshot. v3 deletessys_deploymentand 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.
After v2 we hit two practical problems:
- Phase 0 was a misleading UI relabel.
sys_project.labelwas 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 commit0f412a0f. sys_deploymentsolves 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 asys_package_version; an Environment installs it viasys_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.
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.
| 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.
Renamed / repurposed
sys_projectbecomes 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_id→sys_project(already the case conceptually; now actually correct semantically — was misleadingly pointing at the runtime row before v3).sys_project_member.project_id→sys_project(dev RBAC).sys_environment_memberis a new table (env operator RBAC), peer ofsys_project_member.sys_package_installation.environment_id→sys_environment(wasproject_id→sys_project).sys_oauth_application.environment_id→sys_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_id— optional 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';The Cloudflare Worker resolves <slug>.objectos.app → sys_environment.id
(was sys_project.id). The cache key in multi-project-plugin.ts becomes
hostname → environmentId. The DO singleton hydrates per Environment.
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.
- Consumer first-run is unambiguous. No hidden Project rows, no auto- created "default project" with a fake slug. Account → Env → Install.
- Schema is small and obvious. 1 new table (
sys_environment) + field rename on installations. No join table. - Per-env version pinning is trivial.
sys_package_installationalready carriespackage_version_id; different envs can pin different versions the same package without any new mechanism. - SSO isolation is automatic. Each Environment is its own IdP realm with its own OAuth client; prod tokens cannot be replayed against staging.
- Billing rollup is straightforward. Usage rows carry
environment_id;SUM(usage) GROUP BY organization_idproduces the invoice. - Promotion = SQL. No special "deployment" verb in the API — it's
POST /cloud/environments/:prod/installationswith the samepackage_version_idthe staging env uses.
- 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).
@objectstack/cloudAPI surface change.POST /cloud/projectsis replaced byPOST /cloud/environments;POST /cloud/projectsre-emerges later (different semantics) when Builder UX ships. SDK consumers (none in production yet) must rename calls.apps/cloud/server/index.tsandworker.tschange their hostname resolution fromsys_projecttosys_environment.
sys_project_branchkeeps its name and semantics — was always a dev concept, just had a confused FK target before v3.sys_package_version.published_from_project_idis optional, so platform-seeded starter packages (no Project owner) work the same way.
| 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.
- Multiple Environments per account in free tier? Recommend: 1 free env per Org; paid plans get N. Enforced at provision time.
- 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. - 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.
- 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