Status: Partially implemented (revised 2026-05-30)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0003 (package + versioned releases), ADR-0005 (one Zod source per type, org overlay), ADR-0006 v4 (drop project, unify on package), ADR-0008 (Repository/ChangeLog/Cache/Registry, four write surfaces), ADR-0010 (L1/L2/L3 protection)
Consumers: @objectstack/spec/cloud, @objectstack/runtime, @objectstack/metadata, @objectstack/rest, @objectstack/cli, ../objectui (Studio)
Revision note (2026-05-30) — read §9 first. §§1–8 below capture the original cloud-publish vision (draft
sys_package_versionsealing,package_version_idbinding, one-click publish to a cloud control plane). The first shipped slice pivoted to a local-first path that needs no cloud account, no draft-version sealing, and nopackage_version_id. The two key divergences from the original design are:
- Binding is on
package_id, notpackage_version_id. Studio saves bind a metadata row directly to its owning package (package_id); there is no draft-version workspace in the MVP. Wherever §§1–8 saypackage_version_idfor the Studio authoring binding, readpackage_id.package_version_idremains reserved for the future cloud seal/publish pipeline (§3.5, §4 Phase 4).- Distribution is local export/import, not cloud publish. A package is exported to a self-contained JSON manifest and imported on another server with zero cloud auth (§9.2). Cloud one-click publish (§3.6 step 5) is deferred.
§9 documents what actually shipped; §§1–8 remain the north star for the cloud phase.
Studio is a metadata-driven admin app that already lets a user browse and edit metadata visually. Two recent changes set the stage for a cloud authoring loop:
- A sidebar package scope selector (
active_package) was added; it now lists only third-party (non-kernel) packages, with a "no package" option. - Recently-viewed tracking was fixed for metadata routes.
But the authoring → publish closed loop is not yet implemented. Today:
- Studio create/edit (
ResourceEditPage.tsx:733) callsclient.save(type, name, item, { force, mode:'draft' })with nopackageId. Per ADR-0003 this produces a runtime/overlay row (env_idset,package_idNULL, loaded under sentinel'sys_metadata',provenance='runtime'). It is env-local — it never becomes part of a shippable package. - There are two publish paths (per ADR-0006 v4):
os publish -e <env>→POST /cloud/environments/:id/metadata→ env revision (legacy).os package publish→POST /cloud/packages→POST /cloud/packages/:id/versions→ install (strategic, Phase B). Neither is reachable from Studio. The user must drop to the CLI and author metadata in local files, not in the visual editor.
The user's goal: inside Studio, select (or create) a package, visually author metadata that is bound to that package, preview it, then one-click publish it to the cloud — without leaving the browser and without writing local files.
- A first-class cloud authoring workspace for a package that lives in the control plane (not in env-local overlay rows).
- Studio binds created/edited metadata to the selected package's authoring workspace instead of producing an env-local overlay.
- A preview mechanism so authors see their in-progress package running in a real environment before publishing.
- A one-click publish action that seals the workspace into an immutable
sys_package_versionand installs it into a chosen environment. - CLI parity:
os package publishand the Studio button hit the same control-plane endpoints (ADR-0006 v4 Phase B). - Reuse existing schema as much as possible — minimal new protocol surface.
- Marketplace listing / monetization (separate
service-marketplace). - Multi-author concurrent editing / locking semantics beyond ADR-0010.
- Replacing the local-files +
os publishflow for power users; both coexist (ADR-0006 v4 "two flows, one schema"). - Git-style branching of package versions (single active draft per package per org for v1).
Introduce a draft package version as the cloud authoring workspace, and
make the Studio active_package selector the authoring target.
-
Draft
sys_package_version= authoring workspace. A package may have at most one activedraftversion per org (ADR-0003 already definesstatus ∈ {draft, published, deprecated}). The draft is mutable; metadata rows bound to it carrypackage_version_id = <draft.id>. -
Selector designates the authoring target. When a package is selected in the sidebar, Studio create/edit binds the row to that package's draft version (
package_version_id). When "no package" is selected, the existing behavior holds: a NULL-package env-local overlay (provenance='runtime'). This makes the user's earlier question — "is creating without a package allowed?" — an explicit, intentional choice: no-package = personal/env customization; package = shippable artifact. -
Preview via draft install. A dev/sandbox environment installs the draft version (
allowDraft, per ADR-0003) so authors see their package running live. Preview installs are flagged and never auto-promoted to production. -
One-click publish. The publish action:
- Seals the draft →
published(freezemanifest_json, compute checksum, assign immutable semver). - Upserts
sys_package_installationinto the chosen target environment (pointer swap to the newpackage_version_id). - Opens a fresh
draftfor continued authoring (next version). This reuses ADR-0006 v4 Phase B endpoints — no new publish pipeline.
- Seals the draft →
-
One schema, two write surfaces (ADR-0008). Package-bound edits and env-local overlays are the same
sys_metadatashape, discriminated bypackage_version_id. The ChangeLog records both; the Registry resolves precedence (published < draft-preview < env overlay, see §3.4).
Everything needed already exists from ADR-0003:
| Concern | Existing artifact | Use in this ADR |
|---|---|---|
| Authoring workspace | sys_package_version status='draft' |
The mutable draft per package/org |
| Binding | sys_metadata.package_version_id |
Set to draft id on package-bound edits |
| Env-local overlay | sys_metadata with NULL package |
Unchanged "no package" path |
| Install/preview | sys_package_installation (+ allowDraft) |
Preview + publish target |
| Sealing | sys_package_version.manifest_json + checksum |
Frozen on publish |
New protocol surface is limited to a draft-resolution helper and an
optional is_preview flag on installation (see §3.5). No new top-level
metadata type is introduced.
Studio needs the draft version id for the selected package. Resolution
order when the user selects package P in org O:
- If
Phas an activedraftversion forO→ use it. - Else lazily create a draft (
status='draft', base = latestpublishedversion's manifest, or empty if first version) and use it.
The draft id is held in Studio app context (alongside active_package) and
attached to every save while that package is the authoring target.
ResourceEditPage save changes from:
client.save(type, name, item, { force, mode: 'draft' });to (when a package is the authoring target):
client.save(type, name, item, {
force,
mode: 'draft',
packageVersionId: activeDraftVersionId, // binds to the package draft
});When "no package" is selected, packageVersionId is omitted → unchanged
env-local overlay. The REST save handler routes the row to
package_version_id instead of env_id.
For a given MetaRef = {org, type, name} the Registry (ADR-0008) resolves in
order of increasing specificity:
- Platform-global (both
env_idandpackage_idNULL). - Published package version installed in the env (
package_version_id, immutable, locked per ADR-0010). - Draft-preview (the draft version installed for preview in this env) — shadows the published row so authors see work-in-progress.
- Env-local overlay (
env_idset, NULL package) — highest precedence, personal customization, unchanged.
Draft-preview rows are only visible in environments that explicitly installed
the draft (allowDraft + is_preview); production envs never see drafts.
Reuse ADR-0006 v4 Phase B endpoints; add only what is missing:
| Action | Endpoint | Notes |
|---|---|---|
| Resolve/ensure draft | POST /cloud/packages/:id/draft |
Idempotent; returns active draft version (lazily creates per §3.2) |
| Bind metadata to draft | POST /api/v1/metadata/:type/:name (existing save) |
Now accepts packageVersionId |
| Preview install | POST /cloud/environments/:envId/installations |
{ packageVersionId, isPreview: true } (allowDraft) |
| Publish (seal) | POST /cloud/packages/:id/versions |
Seals draft → published; freezes manifest + checksum |
| Install to target | POST /cloud/environments/:envId/installations |
{ packageVersionId } pointer swap |
isPreview is the one new field on sys_package_installation
(boolean, default false) — distinguishes preview installs from production so
they can be listed/garbage-collected separately.
- Select / create package. Sidebar
active_packageselector gains a "+ New package" item → small wizard (idcom.acme.foo, name, version0.1.0). Creating sets it as authoring target and ensures a draft (§3.2). - Author. Create/edit objects, views, flows, etc. Each save binds to the draft (§3.3). Provenance badge shows Draft (package) vs Runtime (env-local) vs Published (locked) (ADR-0010 lock state drives editability).
- Preview. "Preview" button installs the draft into the author's
sandbox env (
isPreview) and opens the running app. - Review / diff. Show the ChangeLog (ADR-0008) for the draft: what was added/changed vs the last published version.
- Publish. "Publish" button → pick target environment → seal draft → install. Success toast links to the running app and the new version.
os package publish performs the same three control-plane calls (seal →
version → install). The Studio button and the CLI are interchangeable; both
go through POST /cloud/packages/:id/versions. Local-files authors keep
using os publish / os package publish; Studio authors never touch files.
- Draft rows: editable/deletable (L3
_lockabsent). - On publish, the sealed version's rows inherit the package
metadataDefaults.lock(L2) → installed published rows are read-only in consuming envs; customization happens via env-local overlay (path 4 in §3.4), exactly as today. - Platform/kernel packages (
scope ∈ {system, cloud}) remain excluded from the authoring selector — you cannot author into a kernel package.
This realizes "Phase 5 Builder UX" of ADR-0006 v4 inside Studio, layered on the already-shipped Phase B package endpoints.
- Phase 1 — Binding. REST
saveacceptspackageVersionId; metadata router writespackage_version_id.POST /cloud/packages/:id/draft(resolve/ensure draft). Studio attaches the active draft id to saves. - Phase 2 — Provenance UX. Provenance badges (draft / published / runtime)
in
ResourceListPage+ResourceEditPage; "+ New package" wizard in the sidebar selector. - Phase 3 — Preview.
isPreviewinstallation field; "Preview" button + sandbox install + open-app. - Phase 4 — Publish. "Publish" button → env picker → seal + install; ChangeLog diff view; auto-open next draft.
- Phase 5 — Polish. GC of stale preview installs; multi-version history browsing; CLI/Studio parity tests.
- Closes the visual authoring → publish loop entirely in the browser.
- No new metadata type; reuses ADR-0003 draft versions and ADR-0006 v4
endpoints. One
sys_metadatashape, one publish pipeline. - Makes "no package" an explicit, meaningful choice (env-local customization) rather than an accidental default.
- Studio and CLI converge on the same control-plane contract.
- Introduces a stateful "authoring target" (active draft) in Studio context that must be kept consistent with the sidebar selector.
- Preview installs add lifecycle/GC burden (mitigated by
isPreviewflag). - Draft-preview resolution adds a precedence layer to the Registry (§3.4).
- Local-files +
os publishflow is unaffected; both surfaces coexist. - Lock semantics are unchanged — published artifacts stay read-only, overlay customization path is preserved.
- Author directly as env-local overlays, then "promote" to a package. Rejected: promotion would have to reverse-engineer package membership from loose overlay rows, re-introducing the ADR-0003 problem of packages with no identity. Binding at author time is cleaner.
- A dedicated new "authoring session" table. Rejected:
sys_package_versionwithstatus='draft'already is the mutable workspace; a parallel table would duplicate ADR-0003 and fork the publish pipeline. - Git-backed package source in the browser. Rejected for v1: heavyweight; the control-plane draft already provides atomic seal/version semantics.
- Multiple concurrent drafts per package. Deferred: single active draft per package/org keeps resolution unambiguous; branching can come later.
- Draft conflict / multi-author. If two admins edit the same package
draft, do we need optimistic locking on
sys_package_versionrows beyond ADR-0010 L3? (Lean: row-level_versioncheck; defer real collaboration.) - Preview env provisioning. Does each author get an auto-provisioned
sandbox env, or reuse their current env with preview installs? (Lean:
reuse current env +
isPreview, with a visible banner.) - Versioning UX. Auto-bump semver on publish, or prompt? (Lean: prompt with suggested patch bump.)
- Cross-package references. If draft package A references metadata owned by
published package B, how is the dependency recorded in
manifest_json? (Likely an extension of ADR-0003 dependency edges — out of scope here.)
The first shipped slice deliberately avoids the cloud control plane so a user can author a package and move it between servers with no account, no draft sealing, and no network publish. It realizes the author → bind → list → distribute loop locally; the cloud seal/publish pipeline (§§2–4) layers on top later without re-modelling.
Studio's save flow (ResourceEditPage) now passes the owning package id:
client.save(type, name, item, {
force,
mode: 'draft',
packageId: activePackageId, // binds the row to the package directly
});The REST save handler / metadata router persists package_id on the
sys_metadata row. When "no package" is selected, packageId is omitted
and the row stays an env-local overlay (package_id NULL), exactly as §2.2
describes. There is no draft sys_package_version in the MVP — a package
is a flat collection of package_id-tagged metadata. This keeps resolution
trivial (registry items and overlay rows both carry _packageId) and defers
versioning/sealing to the cloud phase.
Migration note: earlier drafts of this ADR (and some code comments) referred to
package_version_idas the Studio binding. The implemented binding ispackage_id.package_version_idis reserved for sealed cloud versions.
A package is portable as a single self-contained JSON manifest.
| Action | Endpoint | Behaviour |
|---|---|---|
| Export | GET /api/v1/packages/:id/export |
Assembles a manifest { id, name, version, label?, <pluralKey>: [...] } from every package_id-bound metadata item (objects, views, apps, flows, …). datasources/emailTemplates are excluded (host-specific). Top-level _-prefixed provenance keys are stripped. |
| Import | POST /api/v1/marketplace/install-local |
Accepts an inline { manifest } body. Skips the cloud-URL guard and cloud fetch entirely; derives packageId/version from the manifest. Still requires an authenticated local session and runs the conflict check. |
Studio surfaces both: an Export action in the package detail sheet
(downloads <id>.package.json via a Blob) and an Import button in the
Packages page toolbar (hidden file input → POST inline manifest → result
banner). See §9.4 for the page entry point.
Export item-set note: export trusts the query-level packageId filter in
getMetaItems rather than re-filtering by _packageId === packageId, because
objects are tagged with a provenance sentinel (_packageId='sys_metadata'),
not the package id. Re-filtering on equality would wrongly drop objects.
On an inline import the manifest is hot-registered into the live engine before anything is persisted. If hot-registration fails (e.g. the package's objects conflict with already-registered objects — which is exactly what happens when re-importing onto the source server), the request fails with 422 and nothing is written. This guarantees an import either fully takes effect in the running process or leaves no partial state behind.
(The pre-existing cloud-fetch import path stays lenient — a hot-register failure there only warns, because the persisted rows will be picked up on the next boot.)
Studio gained a dedicated Packages page (PackagesPage.tsx) — a full list
of installed packages with a detail sheet — not just the sidebar active_package
switcher. The page is the home for: viewing a package's metadata, enable /
disable, export, and import. The sidebar selector remains the
authoring-target picker (which package_id new saves bind to); the Packages
page is the management surface.
PATCH /api/v1/packages/:id/disable (and /enable) flips the package's
enabled/status flags and persists the choice across restarts (§9.7). A
disabled package's metadata must stop surfacing in the console (app switcher,
view lists, dashboards, …). This is enforced in two layers of the read path:
SchemaRegistry.listItems(type, …)filters out items whose owning package is disabled (viaisPackageDisabled(_packageId)), for every type exceptpackage(the Packages page must still list disabled packages so they can be re-enabled) andobject/objects(filtering object schemas would break data queries that depend on them).getMetaItemsre-applies the same filter to the final merged set, because thesys_metadataDB-overlay merge and the MetadataService merge can re-introduce a disabled package's items after the registry filter ran. Apps and views persisted as overlay rows were leaking back without this second pass.
Disable is reversible and non-destructive: items stay registered and reappear on enable. Disable state survives restarts (§9.7).
Export (assemblePackageManifest) is driven by the canonical
PLURAL_TO_SINGULAR map, and import consumption by
engine.registerApp's metadataArrayKeys. Both now include tools (→ tool)
and skills (→ skill), and PLURAL_TO_SINGULAR gained those entries (so the
reverse SINGULAR_TO_PLURAL and export round-trip them automatically).
ObjectStackDefinition also gained a top-level tools field next to agents
and skills for authoring completeness. This covers metadata round-trip and
visibility (getMetaItems('tool' | 'skill')); turning an imported tool
definition into an executable ToolRegistry entry (handler wiring) is a
deeper AI-runtime concern and remains out of scope.
The SchemaRegistry is rebuilt from the compiled artifact on every boot (always
enabled), so disable state would otherwise be lost. Persistence is local-first:
SchemaRegistry.setInitialDisabledPackageIds(ids)seeds a set of ids thatinstallPackagehonors — any package whose id is in the set is installed in thedisabledstate. Because the set lives on the registry for its lifetime, every registration path (boot artifact, marketplace rehydrate, local import) honors it uniformly — no fragile post-boot re-application hook.packages/runtime/src/package-state-store.tspersists the disabled-id set to<OS_HOME>/package-state/<environmentId>.json, keyed per environment so disables never leak between environments.AppPlugin.initreads the persisted set and seeds the registry before the manifest is decomposed.- The
enable/disableHTTP handlers (handlePackages) write the new state to the store after flipping the registry flag.
- CLI export/import (
os package export/import) — deferred; only the REST + Studio surfaces ship in the MVP. - Executable tool wiring on import — imported
toolmetadata is visible and re-exportable, but not registered as an executableToolRegistryhandler (§9.6). - Cloud one-click publish (§3.6 step 5, delegated auth) — deferred to the cloud phase; the local path above is the interim distribution mechanism.
- ADR-0003 — Package + versioned releases
- ADR-0005 — One Zod source, org overlay
- ADR-0006 v4 — Drop project, unify on package; Phase B publish
- ADR-0008 — Repository/ChangeLog/Cache/Registry
- ADR-0010 — L1/L2/L3 protection
packages/cli/src/commands/package/publish.ts— CLI publish (seal → version → install)../objectui/.../metadata-admin/ResourceEditPage.tsx— Studio save flow (binding point)../objectui/.../layout/UnifiedSidebar.tsx—active_packageselector (authoring target)packages/runtime/src/http-dispatcher.ts—assemblePackageManifest+GET /packages/:id/export(§9.2)packages/runtime/src/cloud/marketplace-install-local-plugin.ts— inline-manifest import + register-before-persist (§9.2–9.3)packages/objectql/src/registry.ts—isPackageDisabled+listItemsdisabled-package filter (§9.5);setInitialDisabledPackageIds+installPackagedisable seeding (§9.7)packages/objectql/src/protocol.ts—getMetaItemsfinal-merge disabled-package filter (§9.5)packages/objectql/src/engine.ts—registerAppconsumestools/skills(§9.6)packages/spec/src/shared/metadata-collection.zod.ts—PLURAL_TO_SINGULARgainstools/skills(§9.6)packages/spec/src/stack.zod.ts— top-leveltoolsonObjectStackDefinition(§9.6)packages/runtime/src/package-state-store.ts— per-environment disable-state file (§9.7)packages/runtime/src/app-plugin.ts— seeds persisted disable state before manifest decompose (§9.7)../objectui/.../metadata-admin/PackagesPage.tsx— Packages page + Export/Import UI (§9.4)