|
| 1 | +# ADR-0069: Package-first authoring — every runtime-authored item lives in a writable package (base); no orphans |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-24) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (`allowOrgOverride` / runtime overlay model; one Zod source per type), [ADR-0048](./0048-cross-package-metadata-collision.md) (one app per package; package id is the addressing unit), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI authors metadata as drafts), [ADR-0067](./0067-commit-history-and-rollback-for-ai-authoring.md) (commit history & rollback for AI authoring) |
| 6 | +**Consumers**: `@objectstack/objectql` (`protocol.saveMetaItem` write path), `@objectstack/spec` (metadata-type registry), `../objectui` (Studio create flow, package scope selector, `PackagesPage`), `../cloud` (`service-ai-studio` — `create_metadata` / `resolvePackageId` / `apply_blueprint`) |
| 7 | + |
| 8 | +**Premise**: pre-launch — specify the target end-state. The platform already has the **package** primitive (ADR-0048) and a runtime overlay store (ADR-0005); what is missing is a *contract* that ties every runtime-authored item to a writable package and forbids package-less ("orphan") metadata. The **Airtable model** is the north star: you create a **base** (= a writable software package) first, then author objects/fields/views/flows **into it**; the base is the unit you organise, version, export, and **delete**. Items below are tagged **[existing]** / **[new]**. |
| 9 | + |
| 10 | +> **Trigger**: a business-user dogfood pass of Studio surfaced a family of related defects whose common root is the *absence of a package-first contract*: |
| 11 | +> 1. **Read-only-after-publish** ([framework#2252](https://github.com/objectstack-ai/framework/pull/2252)): authoring a new object while a *code* package was selected stamped the new row with that code package's `package_id`, so it read back as "code-provided" and locked itself read-only the moment it was published — the user could not edit what they had just created. |
| 12 | +> 2. **The orphan pile / no delete unit** ([objectui#1946](https://github.com/objectstack-ai/objectui/pull/1946)): the band-aid for (1) coerces such writes to `package_id = null`. That correctly unblocks editing, but it scatters runtime metadata into a package-less bucket that the package-scoped Studio lists filter *out* — items become hard to find and, critically, **there is no container to delete**: "how do I clean up a pile of loose metadata?" |
| 13 | +> 3. **AI authoring can still orphan**: `../cloud`'s `service-ai-studio` resolves a target package with smart fallbacks (active package -> single-app package -> **auto-created `com.workspace`**) and rejects read-only code packages, but it never *requires* the user/agent to choose a base first — so it can silently default into a catch-all dumping ground. |
| 14 | +> |
| 15 | +> The fixes shipped so far (#2252, #1946) are deliberate **stopgaps**. This ADR replaces them with the proper model. |
| 16 | +
|
| 17 | +## TL;DR |
| 18 | + |
| 19 | +1. **[new] No orphan authored metadata.** Every runtime-*created* metadata item is bound to a **writable package** (`package_id` is a real, writable package id — never `null`, never a code/installed package). The "package-less / `sys_metadata`-scope" state stops being an authoring destination; it remains only as a read-side rehydration detail and a migration source. |
| 20 | +2. **[existing->ruled] Two package kinds.** *Code/installed* packages (manifest `source: 'filesystem'` / installed via marketplace) are **read-only** — you may org-overlay an item where `allowOrgOverride` (ADR-0005), but you may **not** author *new* items into them. *Writable* packages (DB-backed, user/AI-created "bases") are the only authoring targets. |
| 21 | +3. **[new] Create-flow contract — "pick or create a base first".** Before any create, all three surfaces (Studio UI, the AI authoring tools, the kernel write path) resolve to a writable package or **prompt to create one** — no silent default. This is enforced in code, not just prompt guidance. |
| 22 | +4. **[new] The package is the lifecycle unit.** Delete (cascade all its items + data), export, snapshot/version, and duplicate operate on a whole package. This is the answer to "how do I delete the mess." |
| 23 | +5. **[ruled] Retire the stopgaps.** #2252's null-coercion evolves into *redirect-to-a-writable-package*; #1946's "Local / Custom" scope becomes a **migration affordance** ("move these loose items into a base") and is then removed. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## Context: what each surface does today |
| 28 | + |
| 29 | +| Surface | Where new metadata's package comes from | Orphan possible? | Read-only code pkg? | Status | |
| 30 | +|---|---|---|---|---| |
| 31 | +| **Kernel** `protocol.saveMetaItem` (`@objectstack/objectql`) | `?package=` passed by caller; for a `runtime-only` write whose target is a *loaded code package*, the binding is **coerced to `null`** (`isLoadedPackage` guard, #2252) | **Yes** — by design today (the coercion *creates* nulls) | guarded (won't stamp into a loaded code pkg) | **[existing]** | |
| 32 | +| **Studio UI** (`../objectui`) | the package **selector** (`StudioHomePage`/`DirectoryPage`/`ResourceListPage` read `?package=`); a "Local / Custom" scope (`sys_metadata`) surfaces the null bucket (#1946); `PackagesPage` *can* create a writable package, but the create flow doesn't **require** one | **Yes** — default scope is a code pkg or the null bucket | partially (object becomes read-only — fixed for editability, not for *where it lives*) | **[existing]** | |
| 33 | +| **AI authoring** (`../cloud` `service-ai-studio`) | `resolvePackageId` (`metadata-tools.ts:268-328`): explicit `packageId` -> target item's pkg -> conversation active pkg -> single-app pkg -> **auto-create `com.workspace`**; read-only pkgs **rejected** (`:319`); `create_package` / `set_active_package` / `apply_blueprint` (`ensureAppPackage`) exist | **Soft** — auto-defaults into a catch-all instead of requiring a choice | **rejected** (`metadata-tools.ts:319`) | **[existing]** — most advanced of the three | |
| 34 | + |
| 35 | +The primitives already exist on every surface (a package concept; a way to create one; a read-only/writable distinction in cloud). **What is missing is a single, enforced contract** so the three surfaces agree, and so authored metadata always has a managed home. |
| 36 | + |
| 37 | +--- |
| 38 | + |
| 39 | +## Decision |
| 40 | + |
| 41 | +### D1 — Authored metadata is package-bound; orphans are disallowed [new] |
| 42 | + |
| 43 | +A runtime **create** (`intent = runtime-only`, i.e. not an overlay of an artifact item per ADR-0005) MUST resolve to a **writable** `package_id`. The kernel write path is the enforcement point of last resort: |
| 44 | + |
| 45 | +- `protocol.saveMetaItem` gains a notion of *writable-package required for runtime creates*. When the resolved target is missing or is a **code/installed** package, the write is **rejected** with a structured, actionable error (`code: 'writable_package_required'`) rather than silently coerced to `null` (today's #2252 behaviour). |
| 46 | +- `null` / `sys_metadata`-scope ceases to be a *write* destination for new items. It survives only as (a) the read-side rehydration tag for legacy rows and (b) the source set for the D5 migration. |
| 47 | +- Overlays of artifact items (`intent = override-artifact`, `allowOrgOverride`) are **unchanged** — they keep their binding to the packaged item they customise. |
| 48 | + |
| 49 | +### D2 — Two package kinds; only writable packages are authoring targets [existing->ruled] |
| 50 | + |
| 51 | +- **Code / installed packages** — manifest `source: 'filesystem'`, or installed via the marketplace; scope `system` / `cloud` (already filtered out of the Studio selector). **Read-only.** New items may not be authored into them; only `allowOrgOverride` overlays are permitted (ADR-0005). This mirrors Airtable's installed apps/templates. |
| 52 | +- **Writable packages (bases)** — DB-backed, created by a user or the AI (`PackagesPage` create dialog; cloud `create_package`). The only valid authoring destination. Scope `project`. |
| 53 | + |
| 54 | +The kernel exposes a single predicate — *is this package writable?* — reused by all surfaces (cloud's `resolvePackageId:319` read-only check is the seed of this). |
| 55 | + |
| 56 | +### D3 — Create-flow contract: select-or-create a base first, enforced everywhere [new] |
| 57 | + |
| 58 | +Every authoring surface follows the same contract; none silently defaults into a catch-all: |
| 59 | + |
| 60 | +- **Studio UI** [new]: the scope selector defaults to a *writable* package (the user's most-recent base) or, when none exists, the create entry points (`新建`) **prompt to create a base** (reuse the existing `PackagesPage` create dialog) before opening the designer. New items are bound to the active writable package. The "Local / Custom" (`sys_metadata`) scope is removed as a create destination (see D5). |
| 61 | +- **AI authoring** [new] (`../cloud` `service-ai-studio`): `resolvePackageId` **drops the auto-`com.workspace` fallback** (`metadata-tools.ts:304`) and the `applyDraft` re-bind (`:568-573`); when no writable package is resolvable it returns an actionable error directing the agent to `create_package` or `set_active_package`. `apply_blueprint` requires a package context (its `ensureAppPackage` is invoked only after an explicit create/confirm). Skill prompts (`metadata-authoring-skill.ts`, `solution-design-skill.ts`) are updated to make "establish the base first" a hard step, not a hint. |
| 62 | +- **Kernel** [new]: D1 is the backstop — even if a surface regresses, the write is rejected, not orphaned. |
| 63 | + |
| 64 | +### D4 — The package is the lifecycle unit (delete / export / snapshot / duplicate) [new] |
| 65 | + |
| 66 | +Operating on a *base* operates on all of its contents — this is the direct answer to "a pile of loose metadata, how do I delete it?": |
| 67 | + |
| 68 | +- **Delete**: deleting a writable package **cascades** to all its metadata items (and, per the env's data-lifecycle contract, optionally its seeded/created data). Builds on `registry.uninstallPackage` (`@objectstack/objectql`), extended to a *writable-base delete* that removes the DB-backed items it owns. |
| 69 | +- **Export / snapshot / version**: a base can be exported (the "app = package" north star) and snapshotted (ADR-0067 commit history is per-package). |
| 70 | +- **Duplicate**: clone a base into a new writable package (the Airtable "duplicate base" gesture). |
| 71 | + |
| 72 | +### D5 — Retire the stopgaps via a migration affordance [ruled] |
| 73 | + |
| 74 | +- The framework #2252 *coerce-to-null* behaviour is replaced by D1 *reject/redirect-to-writable-package*. Existing `package_id = null` rows are not broken. |
| 75 | +- The objectui #1946 *"Local / Custom"* scope is repurposed as a **one-time migration surface**: it lists the legacy package-less items and offers "move into a base" (assign them a writable `package_id`). Once an environment has no orphans, the scope is removed from the selector. |
| 76 | +- A small migration (or admin action) can bulk-assign legacy orphans to a default base named for the environment. |
| 77 | + |
| 78 | +### D6 — Single-tenant framing; no "org" dumping ground [ruled] |
| 79 | + |
| 80 | +A metadata-customizable deployment is **single-tenant** (an *environment*; `project -> environment`); there is no per-org overlay dimension here. The relevant axis is **code package vs writable base**, not "org". The earlier instinct to make a package-less "org/local" scope the *default* is explicitly rejected: it reproduces the orphan pile. (Multi-tenant hosting keeps `package_id = null` overlays as a *tenant-overlay* mechanism; that is orthogonal and out of scope for this ADR.) |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## Consequences |
| 85 | + |
| 86 | +- **Positive**: every authored item has a managed home; bulk delete/export/duplicate become trivial (whole base); the three surfaces (kernel / Studio / AI) share one contract; AI output is never orphaned; the "read-only after publish" and "where did my object go" classes of bug are designed out, not patched. |
| 87 | +- **Cost / migration**: a create now has a (cheap, one-time per environment) "create your first base" step; existing null-scope rows need the D5 migration; #2252/#1946 code is removed after migration. The cloud `resolvePackageId` fallback removal must be paired with good "no package selected" UX so the AI does not dead-end. |
| 88 | +- **Risk**: over-strict enforcement could block legitimate flows (e.g. seed/import). Mitigation: the writable-package requirement applies to *interactive authoring*; bulk import paths may pre-create a base. Validate against the dogfood gate. |
| 89 | + |
| 90 | +## Open questions |
| 91 | + |
| 92 | +1. **Default base auto-provisioning**: do we auto-create a single "My workspace" base on first authoring (one-click, then required thereafter), or always force an explicit create? (Cloud currently auto-creates `com.workspace`; D3 removes the *silent* version but a *prompted* one-click is acceptable.) |
| 93 | +2. **`allowOrgOverride` types in single-tenant**: in a single-tenant env, is org-overlay of a code item still meaningful, or should customising a code item also fork it into a writable base? (Leaning: keep overlay for surgical tweaks; "fork to base" for substantial changes.) |
| 94 | +3. **Delete-cascade & data**: does deleting a base also delete the *records* of its objects, or only the schema? Ties to the data-lifecycle ADR (retention). |
| 95 | +4. **Naming/id ergonomics**: bases need friendly names but stable ids (`app.<slug>` today). Surface a name; keep the id stable. |
| 96 | + |
| 97 | +## Rollout (tracked separately — see the package-first epic) |
| 98 | + |
| 99 | +1. Kernel D1/D2 predicate + `writable_package_required` rejection (`@objectstack/objectql`). |
| 100 | +2. Studio D3 create-flow + selector default; PackagesPage delete/duplicate (`../objectui`). |
| 101 | +3. AI D3 enforcement in `service-ai-studio` (drop fallback, require package, update skills). |
| 102 | +4. D4 package-as-unit (delete-cascade, export/duplicate). |
| 103 | +5. D5 migration affordance; remove #2252/#1946 stopgaps. |
| 104 | +6. Dogfood-gate coverage for each. |
0 commit comments