Status: Accepted
Date: 2026-04-20
Deciders: ObjectStack Protocol Architects
Supersedes: The flat sys_package_installation (package_id + version string) model introduced alongside ADR-0002
Consumers: @objectstack/spec/cloud, @objectstack/service-tenant, @objectstack/metadata, future service-marketplace, service-solution-history, service-subscription
ADR-0002 established the Control Plane / Data Plane split and introduced sys_package_installation to track which packages are installed in each environment. That model stores a package_id (reverse-domain string) and a version (semver string) on the installation row.
Operating this design reveals four structural problems:
-
Packages have no identity of their own. There is no
sys_packagerow. The platform cannot answer "what packages exist?", "who published them?", or "what is the latest stable version?" without scanning installation records. -
Versions are strings, not references. A version like
"1.2.3"carries no payload. The metadata objects, views, flows, and migrations that constitute that release live outside the model — there is no atomic snapshot to deploy, validate, or roll back. -
Metadata ownership is wrong.
sys_metadatacurrently carriesenv_idto scope schema definitions. But a CRM object definition (account,contact) belongs to a specific package version, not to an environment. Environments only need to record which version is active — they should not own the schema. -
Upgrade and rollback are not atomic. "Upgrade env from v1.2.3 to v1.3.0" should be a single pointer swap (
package_version_id). With the string model it degenerates into multi-row writes with no transactional boundary.
Meanwhile, every mature low-code platform treats packages/solutions as first-class versioned artifacts:
| Platform | Package | Version artifact | Install record |
|---|---|---|---|
| Salesforce | Unlocked Package (0Ho…) |
Package Version (04t…) |
Subscriber org row |
| Power Platform | Solution | Solution Version | Solution in Environment |
| ServiceNow | Application | App Version | Installed Application |
| npm / pip / cargo | Package | Published version tarball | node_modules / venv |
The common invariant: a published version is an immutable snapshot. Installing means pointing an environment at a snapshot; upgrading means pointing at a newer snapshot.
We introduce a three-layer package model in the Control Plane:
Control Plane DB
│
├── sys_package — Package identity (one row per logical package)
├── sys_package_version — Immutable release snapshot (one row per published version)
└── sys_package_installation — Environment ↔ version pairing (replaces old install row)
sys_metadata gains a package_version_id foreign key to express that a metadata record belongs to a package version, not to an environment directly.
| Field | Type | Notes |
|---|---|---|
id |
UUID | Stable identifier |
manifest_id |
text UNIQUE | Reverse-domain e.g. com.acme.crm |
owner_org_id |
text | Organization that publishes this package |
display_name |
text | Human label |
description |
text | Short description |
visibility |
enum | private / org / marketplace |
created_at |
datetime | |
updated_at |
datetime |
| Field | Type | Notes |
|---|---|---|
id |
UUID | Stable, never reused |
package_id |
FK → sys_package | |
version |
text | semver e.g. 1.2.3 |
status |
enum | draft / published / deprecated |
release_notes |
text | Optional changelog |
manifest_json |
JSON | Full package manifest snapshot at publish time |
checksum |
text | SHA-256 of manifest_json for integrity checks |
min_platform_version |
text | Minimum ObjectStack version required |
published_at |
datetime | Null while draft |
published_by |
text | User ID |
created_at |
datetime |
Unique constraint: (package_id, version).
Once status = 'published', manifest_json and checksum are immutable.
| Field | Type | Notes |
|---|---|---|
id |
UUID | |
environment_id |
FK → sys_environment | |
package_version_id |
FK → sys_package_version | replaces package_id + version string pair |
status |
enum | installed / installing / upgrading / disabled / error |
enabled |
boolean | Whether metadata is loaded into this env |
settings |
JSON | Per-installation config overrides |
installed_at |
datetime | |
installed_by |
text | |
updated_at |
datetime | |
error_message |
text | Set when status = 'error' |
Unique constraint: (environment_id, package_id) — derived via package_version_id.package_id. Only one version of a given package may be active per environment at a time.
Upgrade = UPDATE package_version_id to new version's UUID. The old version row remains intact (audit trail). upgradeHistory is removed from the installation row — the history is implicit in the sequence of updated_at snapshots and an optional sys_package_installation_history log table.
sys_metadata gains one new optional foreign key:
package_version_id FK → sys_package_version nullable
Effective query for "what metadata is active in environment E?":
-- 1. All package-owned metadata from installed versions
SELECT m.*
FROM sys_metadata m
JOIN sys_package_installation i ON i.package_version_id = m.package_version_id
WHERE i.environment_id = :env_id
AND i.enabled = true
UNION ALL
-- 2. Environment-level overrides / customizations
SELECT m.*
FROM sys_metadata m
WHERE m.env_id = :env_id
-- Result: overlay env overrides on top of package metadata (same type+name → env wins)Three ownership tiers:
package_version_id |
env_id |
Meaning |
|---|---|---|
| set | NULL | Belongs to a package version (deployed with the package) |
| NULL | set | Environment-level override or custom metadata |
| NULL | NULL | Platform-built-in / global (e.g. sys_user object) |
- Create
sys_packageandsys_package_versiontables (additive, non-breaking). - Backfill: For each distinct
(package_id, version)string pair found in the oldsys_package_installation, create onesys_packagerow and onesys_package_versionrow. Themanifest_jsonfield can be populated lazily (null until the package is re-published through the new flow). - Add
package_version_idcolumn tosys_package_installation. Populate from the backfill mapping. - Drop old
package_id(string) andversion(string) columns fromsys_package_installation— in v5.0 after a deprecation window. - Add
package_version_idcolumn tosys_metadata. Populate for any metadata rows that were installed by a known package version.
The migration is non-destructive and idempotent. Steps 1–4 ship in v4.x as an opt-in; step 4 (column drop) is a v5.0 hard cut.
- Package identity is a first-class query.
GET /cloud/packagesreturns the catalog.GET /cloud/packages/:id/versionslists all releases. - Atomic deploys and rollbacks. Upgrading or rolling back is a single
UPDATE package_version_id. No row-level copy jobs. - Schema ownership is unambiguous. An
accountobject lives insys_metadatawithpackage_version_id = <crm-1.2.3>. It does not belong to any environment — environments only install the version. - Marketplace / App Store foundation.
sys_package.visibility = 'marketplace'is the hook for the public registry (ADR-0004, future). - Integrity guarantees.
manifest_json + checksumon a published version means the platform can verify nothing has been tampered with at install time. - Clean upgrade audit trail. The history of
package_version_idchanges on an installation row (plus an optional history table) is authoritative.
- More join hops for the effective-schema query (env → installations → versions → metadata). Mitigated by the metadata cache layer in
MetadataManager. - Backfill cost for existing deployments —
manifest_jsonis not available for legacy string-version installations. Lazy population is acceptable for most cases. - Draft versions must not be accidentally installed in production. Enforcement: install API rejects
status != 'published'unlessallowDraft = trueflag is set (dev/sandbox envs only). - One version per package per environment is a hard constraint. Side-loaded / multi-version installs are explicitly out of scope (same trade-off as npm's
peerDependenciesmodel).
- No change to
sys_environment,sys_environment_member, orsys_database_credential. - No change to the business data in environment DBs.
- No change to
env_id = NULLmeaning "platform-global" for metadata without a package owner. better-authsession shape is unchanged.
- Keep
package_id + versionstrings, add a separate version catalog table but don't FK it. Rejected — without a hard FK the catalog can drift from installations, defeating the integrity argument. - Embed the full manifest in each installation row. Rejected — N environments × M packages = N×M copies of the same JSON. The version table is the single source of truth.
- Move package versioning entirely to the filesystem / Git. Rejected — query-ability (list installed packages, filter by status, detect conflicts) requires a database-backed model.
- Allow multiple active versions of the same package per environment. Rejected — conflict resolution between overlapping metadata definitions is intractable. One version per package per env, same as every comparable platform.
packages/spec/src/cloud/environment-package.zod.ts— current installation schema (to be updated)packages/services/service-tenant/src/objects/sys-package-installation.object.ts— DB object (to be updated)- ADR-0002:
docs/adr/0002-environment-database-isolation.md— Control Plane / Data Plane split - Salesforce Unlocked Packages: https://developer.salesforce.com/docs/atlas.en-us.pkg2_dev.meta/pkg2_dev/
- Power Platform Solution Layers: https://learn.microsoft.com/power-platform/alm/solution-layers-alm
- ServiceNow Application Management: https://docs.servicenow.com/bundle/washingtondc-application-development/page/build/applications/concept/application-management.html