Status: Proposed (2026-06-01)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0002 (per-environment database), ADR-0003 (package · version · installation), ADR-0005 (one Zod source per type, org overlay), ADR-0006 v4 (unify on package, drop project), ADR-0008 (Repository · ChangeLog · Cache · Registry; four write surfaces), ADR-0010 (L1/L2/L3 protection), ADR-0016 (Studio authoring loop — this ADR revives its §2 draft-workspace north-star), ADR-0019 (approvals)
Related (boundary): ADR-0025 (plugin package distribution — code + dependencies) and ADR-0026 (client-side UI plugin distribution) own how a sealed package is distributed and installed; ADR-0027 owns how a package is authored, staged, sealed, and promoted across environments. The two meet at the sealed sys_package_version artifact: this ADR produces it, those ADRs ship it.
Consumers: @objectstack/rest (HTTP /meta/* + /api/v1/cloud/packages/* routes), @objectstack/objectql (storage + schema-sync + destructive-check adapters), @objectstack/runtime (kernel bootstrap; owns env activation / install-pointer swap), @objectstack/cli (os package publish), @objectstack/plugins/plugin-approvals (publish gate), ../objectui (Studio)
Authoring a metadata-driven app is a multi-stage business process, not a single write. This ADR defines that process and the package that owns it:
open draft → stage edits → validate+diff → sandbox preview
→ publish (seal + batch-migrate + activate)
→ promote Dev→Staging→Prod (approval-gated)
→ rollback / deprecate / distribute
Two cadences underpin it:
- Stage — high-frequency, cheap, zero runtime/DDL impact. Each visual edit accumulates as a draft bound to a package version under development.
- Publish — low-frequency, batched, transactional. The whole draft is validated as a set, its migration plan computed and previewed, the physical DDL runs once, the version is sealed (immutable, semver + checksum), and the target environment's install pointer is swapped — activating atomically.
Key finding from the codebase: the data model for all of this already
exists (sys_package, sys_package_version{status: draft|published|deprecated},
sys_package_installation{packageVersionId, status}, sys_environment,
sys_metadata{state, package_version_id}), and destructive-change detection
already exists in objectql. What is missing is the orchestration — a single
owner that sequences stage → diff → publish → promote → rollback across these
tables. @objectstack/metadata-authoring is that owner: server-side,
transport-agnostic, depending only on spec + metadata-core, reaching
storage/DDL/activation through injected ports (adapters in
objectql/runtime). Every surface — Studio, REST, CLI, AI agent, Git webhook —
drives the same lifecycle.
| System | Edit sandbox | Staging unit | Publish action | DDL timing | Promotion | Rollback |
|---|---|---|---|---|---|---|
| Salesforce | Sandbox / Scratch Org | Change Set / Unlocked Package version | Metadata API Deploy / install | At deploy | Sandbox→Prod change set | Re-deploy prior version; drop-field manual |
| ServiceNow | Dev instance + Update Set | Update Set (scoped app) | Commit / move Update Set | At commit | Dev→Test→Prod instances | "Back out" the Update Set |
| Mendix | Working copy + Team Server | Model revision / branch | Deploy builds package, runs DB sync | At deploy | Dev→Acc→Prod | Redeploy prior; forward-only |
| OutSystems | Service Studio module | Module version | 1-Click Publish | At publish | LifeTime Dev→QA→Prod | Revert to prior version |
| Hasura | Console dev mode | Migration files + metadata | apply to an env |
Explicit migration | per-env apply | down migrations |
Consensus this ADR adopts: editing ≠ committing; the publish unit is an immutable package version; DDL runs at publish, batched and previewable; activation is a pointer swap; promotion across environments reuses the same machinery; rollback swaps the pointer back.
| Capability | Where | Notes |
|---|---|---|
| Package identity | spec/src/cloud/package.zod.ts |
manifestId, visibility ∈ {private,org,marketplace}, publisher |
| Versioned snapshot | spec/src/cloud/package-version.zod.ts |
version (semver), status ∈ {draft,published,deprecated}, manifestJson, checksum, dependencies[{packageId,versionRange,optional}], minPlatformVersion, isPreRelease |
| Env install pointer | spec/src/cloud/environment-package.zod.ts |
packageVersionId (the pointer), status ∈ {installed,installing,upgrading,disabled,error}, enabled, withSampleData |
| Environment | spec/src/cloud/environment.zod.ts |
per-env DB (ADR-0002); type ∈ {production,sandbox,development,test,staging,preview,trial} (advisory); members ∈ {owner,admin,maker,reader,guest} |
| Metadata row | platform-objects/.../sys-metadata.object.ts |
state ∈ {draft,active,archived,deprecated}, package_version_id FK, managed_by, scope |
| Destructive detection | objectql/.../protocol-destructive.test.ts |
field_removed, field_type_change, field_required_no_default; force bypass |
| Schema DDL | driver-sql ISchemaDriver |
createCollection/addColumn/modifyColumn/dropColumn, syncSchemasBatch |
| Publish endpoints | cli/.../package/publish.ts |
POST /api/v1/cloud/packages → /versions → install; allowDraft for dev/sandbox |
| Approvals | plugin-approvals |
sys_approval_request/action — exists, not yet wired to publish |
The §9 MVP of ADR-0016 took a shortcut: edits write live sys_metadata
overlay rows bound to a flat package_id, immediately active. This ADR revives
§2's north-star — drafts bound to a draft version, sealed on publish.
| Role | Can stage | Can publish (seal) | Can promote to prod | Can rollback |
|---|---|---|---|---|
maker |
✓ | ✓ (to dev/sandbox) | — (requests approval) | — |
admin |
✓ | ✓ | ✓ | ✓ |
owner |
✓ | ✓ | ✓ | ✓ |
reader/guest |
— | — | — | — |
┌── DEV ENVIRONMENT (type: development) ─────────────────────────────┐
│ │
│ (A) openDraft(pkg) ── draft sys_package_version{status:draft} │
│ │ │
│ (B) stage(change) ×N ── sys_metadata{state:draft, │
│ │ package_version_id=draft.id} (no DDL) │
│ │ │
│ (C) diff()/validate() ── plan = draft vs active; set-validation │
│ │ │
│ (D) preview ── install draft into dev env (allowDraft=true) │
│ │ sys_package_installation.packageVersionId=draft │
│ ▼ │
│ (E) publish(pkg,{targetEnv:dev}) │
│ 1 validate set 2 plan migration 3 dry-run preview │
│ 4 batch DDL 5 SEAL → status:published, semver, checksum │
│ 6 swap install pointer (activate) 7 open next draft │
└────────────────────────────────┬────────────────────────────────--┘
│ (F) promote (approval-gated)
▼
┌── STAGING (type: staging) ─────────────────────────────────────────┐
│ install the SAME sealed version → run its migration → activate │
└────────────────────────────────┬────────────────────────────────--┘
│ (F) promote (approval-gated, admin)
▼
┌── PRODUCTION (type: production) ───────────────────────────────────┐
│ install the SAME sealed version → migration (destructive-gated) │
│ (G) rollback = swap pointer back to prior published version │
└─────────────────────────────────────────────────────────────────--┘
The unit that flows between environments is the sealed sys_package_version,
never raw rows — identical to Salesforce package versions / OutSystems module
versions.
openDraft publish(seal) deprecate
∅ ───────────────▶ draft ───────────────────▶ published ───────────▶ deprecated
│ ▲ │ ▲
stage/discard │ └── open next draft ───────┘ │ rollback target
▼ (after publish) │ (pointer swaps here)
(mutable) (immutable)
- draft — mutable; only dev/sandbox may install it (
allowDraft). Holds the stagedsys_metadata{state:draft, package_version_id=draft.id}rows. - published — immutable (frozen
manifestJson+checksum+ semver); installable into any environment. This is the promotion/rollback unit. - deprecated — published-but-discouraged; blocks new installs unless
allowDeprecated.
Installation status (installing → upgrading → installed | error | disabled)
tracks the per-environment apply; activation succeeds only when the pointer swap
- migration complete.
Ship packages/metadata-authoring (@objectstack/metadata-authoring) — the
transport-agnostic owner of the lifecycle above.
In: orchestration of openDraft / stage / discard / diff / validate / preview / publish / promote / rollback / deprecate; the migration-plan (diff)
engine; the publish-time DDL batching + activation sequencing; approval-gate
invocation.
Out (depended on, never re-implemented): Zod schemas (spec); storage
(objectql SysMetadataRepository, the sys_* objects); DDL execution
(driver-* ISchemaDriver); env DB routing & install-pointer persistence
(runtime); HTTP (rest); the visual editor (../objectui).
(A) Open workspace. openDraft(pkgRef, orgId) ensures ≤1 active draft
sys_package_version per package per org (ADR-0016 §2.1). Idempotent; returns
the draft id all staging binds to.
(B) Stage. stage(change) runs per-item validation only —
Zod (spec) + ADR-0010 protection + allowOrgOverride whitelist — then writes
sys_metadata{state:'draft', package_version_id=draft.id}. No DDL, no
activation. OCC is per-row within the draft (checksum/parentVersion).
discard(ref) removes a staged row; "no package" selected ⇒ legacy env-local
overlay path (ADR-0016 §9), the only non-staged route.
(C) Validate & diff. diff(pkgRef) returns a MigrationPlan = the schema
delta between the draft and the currently-active sealed version, plus a
set-level validation (cross-references resolve, no dangling refs, no
duplicate FQNs) that per-item staging cannot catch.
(D) Sandbox preview. Install the draft into a development/sandbox
environment with allowDraft=true (sys_package_installation.packageVersionId = draft.id), so authors run their in-progress package live before sealing. Never
auto-promoted.
(E) Publish (seal + migrate + activate). publish(pkgRef,{targetEnv}):
- Validate the set (C).
- Plan migration —
diff→ orderedSchemaChange[]+backfills[]. - Preview (
dryRun:true) returns the plan without executing. - Execute L2 DDL, batched (
syncSchemasBatch), destructive-gated (§4). - Seal →
status:'published', freezemanifestJson, computechecksum, assign semver; flip the draft'ssys_metadatarowsstate:'draft' → 'active'. - Swap install pointer — upsert
sys_package_installationfortargetEnvto the newpackageVersionId(statusupgrading → installed); one registry invalidation + one ChangeLog event. - Open next draft for continued authoring.
(F) Promote across environments. Promotion = installing the same sealed
version into the next environment up (Dev→Staging→Prod), reusing
InstallPackageToEnvironment + the same migration executor. Gated by approval
(§6) and role (promote-to-production requires admin/owner). No re-seal — the
checksum that ran in staging is the checksum that runs in prod (Salesforce/
OutSystems parity).
(G) Rollback. Swap sys_package_installation.packageVersionId back to a
prior published version (atomic pointer move) + run the reverse migration.
Reverse DDL is forward-only-with-compensation by default (Mendix/Rails norm):
additive reversals (re-add a column) are auto-generated; destructive reversals
(restore dropped data) are surfaced, not auto-run.
(H) Deprecate & distribute. deprecate(versionRef) flips status:'deprecated'
(blocks new installs). Distribution reuses ADR-0016 §9 export/import of a sealed
version (zero-cloud) and the visibility:'marketplace' publish path.
| Phase | Object touched | Effect |
|---|---|---|
| A openDraft | sys_package_version |
insert {status:'draft'} |
| B stage | sys_metadata |
upsert {state:'draft', package_version_id=draft.id} |
| C diff | (read) sys_metadata + active version |
compute plan |
| D preview | sys_package_installation |
pointer → draft.id (allowDraft) |
| E publish | sys_package_version, sys_metadata, physical tables, sys_package_installation |
seal + DDL + rows→active + pointer swap |
| F promote | sys_package_installation (+ physical tables in target env DB) |
install sealed version in next env |
| G rollback | sys_package_installation (+ reverse DDL) |
pointer → prior version |
| H deprecate | sys_package_version |
status:'deprecated' |
- MigrationPlan =
{ changes: SchemaChange[], backfills: string[], destructive: boolean }, computed from the field-level diff of draft vs active object definitions. - Reuse existing destructive detection (
field_removed,field_type_change,field_required_no_default). Policy by environment type: destructive changes are blocked onproductionunless an explicitforce+ approval; allowed ondevelopment/sandbox. - Backfill:
field_required_no_defaultrequires either a default or a backfill expression before the column can beNOT NULL— surfaced in the plan. - Atomicity (publish only). DDL is frequently non-transactional (MySQL
implicit-commit; Mongo/Memory none). So: (1) plan, (2) apply DDL first —
failure means nothing sealed/activated, abort; (3) seal + pointer swap +
changelog inside
TransactionPort.run(). If step 3 fails after DDL, emit aschema-aheadrepair event rather than fake a DDL rollback; idempotentsyncSchemasreconciles. Because activation is a pointer swap, a failed publish leaves the previously active version serving traffic untouched. - Expand-contract (baked into the model now, executed in M4). A
MigrationPlanis an ordered list of steps, not a flat diff, precisely so a production change can run the zero-downtime sequenceadd column → backfill → switch reads → drop old— possibly spanning two sealed versions. M2 ships the naive "apply the diff" executor; the plan shape already supports the multi-step form so M4 adds it without re-modelling. This is whyMigrationPlanseparateschangesfrombackfillsand carries adestructiveflag rather than being a single DDL string. - Per-target-environment execution. Because each environment has its own
physical database (ADR-0002),
SchemaSyncPort.applyandInstallationPortoperate against the target environment's engine/driver, not the control plane's. The executor is parameterized bytargetEnvfrom publish/promote — the same sealedchecksumruns against Dev's DB, then Staging's, then Prod's.
- One active draft per package per org (ADR-0016 §2.1) — serializes the authoring workspace; avoids divergent drafts in v1 (Git-style branching is a non-goal, matching ADR-0016).
- Per-row OCC inside the draft (
checksum/If-Match): two makers editing different objects don't conflict; editing the same row raisesConflictError. - Advisory edit locks (optional, v2): soft-lock a metadata item to a maker while open in Studio.
- Wire
plugin-approvals(sys_approval_request) as a publish/promote gate:promote(...,{targetEnv:prod})raises an approval request; the install pointer swaps only on approval. Configurable per environment (prod gated, dev open). - Every transition emits a ChangeLog event (ADR-0008) +
sys_metadata_audit(ADR-0010) row: who staged / sealed / promoted / rolled back, with checksums.
class MetadataAuthoringService {
// workspace
openDraft(pkg: PackageRef, orgId: string): Promise<DraftHandle>;
// stage (Phase B) — no DDL, no activation
stage(change: StageChange): Promise<StageResult>;
discard(ref: MetaRef, draft: DraftHandle): Promise<void>;
// review (Phase C)
diff(draft: DraftHandle): Promise<MigrationPlan>;
validateSet(draft: DraftHandle): Promise<ValidationReport>;
// publish (Phase E) — seal + batch DDL + pointer-swap activate
publish(draft: DraftHandle, opts: { targetEnv: string; dryRun?: boolean; force?: boolean }): Promise<PublishResult>;
// promote (Phase F) — same sealed version into next env, approval-gated
promote(versionId: string, opts: { targetEnv: string; force?: boolean }): Promise<PromoteResult>;
// rollback / deprecate (Phase G/H)
rollback(opts: { targetEnv: string; toVersionId: string }): Promise<PromoteResult>;
deprecate(versionId: string): Promise<void>;
}
interface StageChange { op:'put'|'delete'; type:string; name:string; item?:unknown;
orgId:string; draft:DraftHandle; actor?:string; parentVersion?:string }
interface SchemaChange { kind:'create_table'|'add_column'|'modify_column'|'drop_column'|'create_index'|'drop_index';
table:string; detail:string; destructive:boolean }
interface MigrationPlan { changes:SchemaChange[]; backfills:string[]; destructive:boolean }
interface PublishResult { packageVersionId:string; semver:string; plan:MigrationPlan;
activatedEnv:string; changeLogSeq:number }interface DraftWorkspacePort { get; put; delete; list; } // draft-scoped sys_metadata
interface PackageVersionPort { openDraft; seal; getActive; deprecate } // sys_package_version lifecycle
interface InstallationPort { activate(env,versionId); current(env) } // sys_package_installation pointer
interface SchemaSyncPort { plan(objs,prev):SchemaChange[]; apply(targetEnv,changes,tx) } // per-env ISchemaDriver + destructive check
interface ApprovalPort { request(kind,ctx):Promise<ApprovalOutcome> } // plugin-approvals
interface ChangeLogPort { append(event):Promise<number> }
interface RegistryPort { invalidate(ref); broadcast(event) }
interface TransactionPort { run<T>(fn):Promise<T> } // engine.transaction()spec (contracts / Zod)
▲ ▲ ▲
│ │ └── metadata-authoring ──┐ depends: spec, metadata-core, PORTS only
│ │ │ (NOT objectql / runtime directly)
│ ├── objectql ──(impl ports)─┤ DraftWorkspace / SchemaSync(+destructive) / Tx
│ ├── runtime ──(impl ports)─┤ PackageVersion / Installation / env activation
│ └── plugin-approvals ───────┘ Approval
└── metadata-core (Repository iface / ChangeLog / canonicalize / errors)
▲
rest → metadata-authoring HTTP ⇄ lifecycle calls
cli → metadata-authoring `os package publish` → publish()/promote()
See §4 — DDL-first, then seal+swap inside a transaction, schema-ahead
compensation for the non-transactional gap, pointer-swap activation isolating
in-flight failures from live traffic.
A mature metadata-authoring platform (Salesforce DX / OutSystems / Mendix class)
needs the layers below. ADR-0027 designs all of them; the ports, the ordered
MigrationPlan, the sealed-version artifact, and per-target-env execution are
baked in now so later layers slot in without re-modelling.
| Layer | Long-term capability | First delivered |
|---|---|---|
| Authoring | draft workspace · stage/discard · per-item + set validation · live diff | M1 |
| Migration | schema diff → plan · dry-run preview · destructive gating · expand-contract zero-downtime · data backfill expressions | M2 (naive) → M4 (expand-contract/backfill) |
| Release | seal immutable version (semver+checksum) · pointer-swap activation · open next draft | M2 |
| Environments | per-env DB execution · Dev→Staging→Prod promotion · ephemeral preview envs | M3 (promotion) → M4 (ephemeral) |
| Governance | approval gates · RBAC by role · L1/L2/L3 protection · audit/changelog · prod destructive policy | M3 |
| Recovery | rollback (pointer swap + reverse migration) · drift detection / schema-ahead reconcile · post-activate health checks | M3 (rollback) → M4 (drift/health) |
| Distribution | local export/import (§9) · marketplace · dependency resolution (versionRange) · upgrade paths — mechanics owned by ADR-0025/0026; this ADR only produces the sealed artifact they ship |
§9 exists → M4 |
| Collaboration | edit locks · branch + merge · multi-author drafts | M5 |
| Source duality | DB-backed drafts and file/Git authoring seal into the same version (ADR-0006 "two flows, one schema") | seal accepts either source from M2 |
Explicitly deferred (but architecturally provided for): expand-contract migrations, Git-style branching/merge, marketplace dependency resolution, ephemeral preview environments, drift detection. None of these force a redesign because the artifact (sealed version) and the plan (ordered steps) already carry the necessary shape.
Guiding principles: (1) every milestone delivers user-visible value on its own; (2) every milestone is non-breaking and coexists with the §9 live-overlay MVP until M5 retires it; (3) risk is sequenced — read-only/no-DDL first, production DDL later, collaboration last.
| Milestone | Value delivered (why ship it) | Scope | Risk | Coexistence |
|---|---|---|---|---|
| M0 — Seams | De-risks everything: stable contracts to build against | empty package, ports.ts, StageChange/MigrationPlan/PublishResult types, state-machine doc |
none (no behavior change) | n/a |
| M1 — Staging | "Edit safely without touching prod; see exactly what changed." The single biggest UX/safety win, with no DDL risk | openDraft + stage/discard (draft-version-tagged rows); diff/validateSet + dry-run preview (read-only) |
low (no writes to physical schema) | Studio save→stage(); publish still uses existing path |
| M2 — Publish | "Edit in UI → publish → live." The closed loop; replaces per-edit live overlay with explicit, batched publish | migration-plan executor (naive diff) · seal version · pointer-swap activation · open next draft; wire os package publish + Studio publish button |
medium (first prod-path DDL) | "no package" overlay path unchanged |
| M3 — Promote & govern | "Safe production rollout." Enterprise-readiness | promote Dev→Staging→Prod (same sealed checksum) · approval gates (plugin-approvals) · destructive gating by env type · rollback (pointer swap + additive reverse) |
medium-high (cross-env, prod policy) | per-env DBs already isolated (ADR-0002) |
| M4 — Advanced migration & DX | Zero-downtime prod changes; full surface parity | expand-contract migrations · data backfill expressions · drift detection / health checks · CLI/AI/Git surface parity · marketplace dependency resolution | high (zero-downtime correctness) | additive to M2 executor |
| M5 — Collaboration | Multi-author teams | edit locks → branch + merge → concurrent drafts; retire §9 flat package_id rows (migrate to version-bound) |
high (merge semantics) | final consolidation |
Recommended MVP cut: M0 + M1 + M2 is the smallest end-to-end product — it delivers the whole "edit → stage → preview → publish → live" loop a low-code user expects, defers all the genuinely hard parts (cross-env promotion, zero-downtime migration, branching), and never breaks the existing flow. M3 follows immediately for any production/multi-env customer.
- A named owner for the whole authoring lifecycle; every surface (Studio / REST / CLI / AI / Git) drives the same path (ADR-0008 goal; ADR-0016 CLI parity).
- Editing is free and safe; production schema changes are batched, previewed, approval-gated, and reversible by pointer swap.
- Reuses the entire existing data model — no new tables; the net-new code is the orchestration + diff/plan engine.
- Promotion across
development→staging→productionfalls out of the same install-pointer mechanism + per-environment-type policy.
- Significant orchestration surface; the diff/migration-plan engine and the reverse-migration path are genuinely hard and need per-driver integration tests (sql, sqlite-wasm, mongodb, memory).
- Destructive reversal (un-dropping data) is forward-only-with-compensation; true data restore is out of scope (industry norm).
- Reconciling §9 flat-
package_idrows already in the wild with version-bound drafts needs a one-time migration (treat them as the package's published seed). - "One active draft per package/org" defers multi-branch authoring (accepted v1 limitation, per ADR-0016 non-goals).
| Needed | Status |
|---|---|
| package/version/installation/env/metadata schemas | exists (spec/src/cloud/*, sys-metadata) |
| destructive-change detection | exists (objectql) |
| DDL execution + batch sync | exists (ISchemaDriver, syncSchemasBatch) |
| publish/install REST + CLI | exists (/api/v1/cloud/packages, os package publish) |
| approvals primitive | exists (plugin-approvals) |
| draft-version staging binding (stage/discard) | net-new |
| diff / migration-plan engine + dry-run preview | net-new |
| publish-time batched DDL + seal + pointer-swap orchestration | net-new |
| promotion + approval-gate wiring | net-new (primitives exist) |
| reverse-migration / rollback executor | net-new |
See "## Long-term north star & phased delivery" below.
- Per-edit
commit(first draft). Rejected:ALTER TABLEon every canvas click; no staging, no reviewable unit, dangerous on production data. - Keep §9 flat live-overlay binding only. Rejected: edits immediately live and tied to a person/env; no draft to review, no shippable versioned unit.
- Branch-per-author (Git-style) drafts in v1. Deferred: one active draft per package/org keeps resolution trivial (ADR-0016 non-goal); revisit with merge.
- Fold into
objectql/rest. Rejected: couples the lifecycle to the data engine or to HTTP, blocking four-surface reuse (CLI/AI/Git).