Skip to content

Commit 6845035

Browse files
fix(metadata-protocol)!: refuse the per-item publish and a package-less save over a binding to an uninstalled package (#27084)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XA5xtdrvS2hp5TUtrhbCBWH)_ Fixes #26921 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: not needed — no `skills/**` page teaches a save naming no package over a package-bound item or the per-item publish (`git grep` on `skills/` for `/publish` and `discard-drafts` finds one row, the `publish-drafts` event in `skills/objectstack-platform/references/plugin-hooks.md`, which this does not change); the docs paragraph in `content/docs/concepts/metadata-lifecycle.mdx` is updated here ## 维护者速读 - 改了什么:已经绑在"未安装包"上的旧元数据行,不再能被延续或发布:不带 `?package=` 的保存(草稿和直接保存)若沿用了这种绑定,回 422 `WRITABLE_PACKAGE_REQUIRED`;单项发布 `POST /meta/:type/:name/publish`(带或不带 `?package=`)若草稿会发布到这种包里,同样回 422。什么都不写,也不迁移、不删除旧行。 - 为什么改:#26920 堵住了"新建"幽灵绑定,但旧库里已有的幽灵行仍能被无包保存继续写、被单项发布推上线,上线后没有命名空间、无法卸载或导出。 - 风险与回滚:只收紧两个请求,不改存储形状;旧幽灵行照常可读、可删(`DELETE ?package=ID`)、可丢草稿(`discard-drafts`),装上同名包后又能正常保存和发布(实测)。回滚即还原本 PR,无数据迁移。 - 先看:`packages/metadata-protocol/src/protocol.ts` 里 `uninstalledNamedBaseRefusal`(新增的 `how` 参数与三种句子)、`saveMetaItem` 中紧跟包地址解析之后的继承分支、`promoteDraftForPublish` 中地址解析之后的发布分支。 - 两分钟验证:在一个 #26920 之前写过 `PUT /api/v1/meta/dashboard/x?package=com.example.nosuch` 的库上启动本分支,`PUT /api/v1/meta/dashboard/x`(不带包)与 `POST /api/v1/meta/dashboard/x/publish?package=com.example.nosuch` 都应回 422 `WRITABLE_PACKAGE_REQUIRED`,`GET` 读到的行不变。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/metadata-protocol`** — two more doors ask the one named-base rule (`uninstalledNamedBaseRefusal`, the helper the save door and the flow arm ask since #26708), about a binding the caller did not name but the item's stored rows give: - **A save naming no package**, draft or active. Right after the save's address is resolved (the publish-mode `packagelessAddress`, and for a draft save the `packagelessDraftAddress` the object overlay check already read, now read once for every type), the package that address lands in must be installed. Refused `422 WRITABLE_PACKAGE_REQUIRED` with `packageId` = that id, before anything is written: "Cannot save TYPE 'NAME' without naming a package: its stored rows are bound to package 'ID', which is not installed in this deployment, and this save would extend that binding. …". It is never re-homed package-less in silence. Saves naming a package (already judged as named), the server-stated rewrites (`migrate-stored`, `package-duplicate`) and an ambiguous draft address (the repository's `409`, as before) are not asked. - **The per-item publish** (`promoteDraftForPublish`, so `POST /meta/:type/:name/publish` named or not): right after the draft address is resolved, the package the draft goes live in must be installed. Refused the same way before the draft is read: "Cannot publish TYPE 'NAME' into package 'ID': no package with that id is installed in this deployment, so the TYPE would go live bound to a package that does not exist. …". - Same rule, envelope and code; the helper takes `how: { verb?, inherited? }` and only the sentence changes. The flow arm's sentence and the named-save sentence are byte for byte unchanged. Both new sentences name the way out that exists for such rows: create a package with that id, or remove the rows bound to it (a delete naming the id; `discard-drafts` for drafts) and save again. They stay under the REST door's 500-character client message bound for ordinary names (measured: a first draft of the save sentence was cut by `truncateClientMessage`). - ⛔ No stored row is migrated or deleted; no error code was added; `packages/spec` is untouched. - Docs: the package-first authoring paragraph in `content/docs/concepts/metadata-lifecycle.mdx`; changeset `26921-named-base-stored-binding.md` (patch, BREAKING banner, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`). ## Why After #26708 (PR #26920) a save or a `publish-drafts` naming an uninstalled package id is refused, but rows bound to such an id that a database already holds (written before that change) could still be extended or published by two doors: a save naming no package inherited the phantom binding (#11087's rule), and the per-item publish served a phantom-bound draft live. Such an item has no manifest, so no namespace, and no uninstall or export. **Measure-first (the order's stop condition on the inherit arm): a refusal does NOT strand a published phantom row.** On a database written by `main` before #26920 (`7eb613c3`), with five dashboards bound to `com.example.nosuch` (active only, active + draft, draft only): `DELETE /api/v1/meta/dashboard/NAME` answered 200 and removed the active row, with `?package=com.example.nosuch` too; `DELETE …?state=draft&package=com.example.nosuch` discarded the draft; and `POST /api/v1/packages` with that id answered 201, after which a save naming no package over the row answered 200. The served item read already reports `deletable: true`. So the arm was implemented, not handed back. ## Tests - New `packages/metadata-protocol/src/protocol.named-base-stored-binding.test.ts` (16 tests). Rows are written the way they were in production: saved while the package was installed, then the package leaves the registry. Refusals (11): the per-item publish named (dashboard, page), naming none over a phantom draft, naming none over a phantom active row; a save naming no package over a phantom active row (dashboard, page × draft, active); a draft save over a lone phantom draft. Each asserts `code`, `status`, `packageId`, the first sentence, rows unchanged and nothing registered or inserted. Controls (5): saves and publishes over an installed package's rows, package-less items, and the delete naming the id still removing the row. - Red first: with `protocol.ts` at the base blob (`c3c93949`, written over and restored by hash), the file fails 11 of 16; the 5 controls pass. - Fixtures (each now installs the package its rows are bound to, ⛔ none widens the rule): `protocol.batch-verb-driver-code.test.ts`, `protocol.batch-verb-driver-text.test.ts` and `protocol.destructive-409-face-inventory.test.ts` (their registry double gains `getPackage` for `com.acme.crm`); `objectql/src/protocol-sentinel-owner-draft-save-address.test.ts` (installs `com.qa.p` / `com.qa.q`, as `protocol-adopt-orphans-object-registry.test.ts` does). - `protocol.package-closure-gate.test.ts` › "narrows NOTHING when the registry cannot produce the written package" pinned exactly the promotion this card refuses (a draft promoted after its package left the registry, unnarrowed, `success: true`). Replaced, not narrowed: renamed to "a promotion into a package the registry cannot produce is refused before the closure gate"; the installed leg still asserts the closure refusal, the uninstalled leg now asserts `422 WRITABLE_PACKAGE_REQUIRED`. - Package suites (vitest, `--maxWorkers=2`, under the verify lock), after rebuilding `@objectstack/metadata-protocol` so the consumers read the changed `dist/`: <details><summary>Counts</summary> - On the final head `6135db9b`: `@objectstack/metadata-protocol` 277 files passed / 4 skipped, 21749 tests passed / 26 skipped; `@objectstack/objectql` 477 files, 8893 tests passed. - On `0842cef6` (the same source as the final head for these packages, before merging `main`, whose incoming commits touch none of these doors): `@objectstack/runtime` 8529 passed / 19 skipped; `@objectstack/rest` 6827 passed / 344 skipped; `@objectstack/client` 678 passed; `@objectstack/plugin-security` 5049 passed / 45 skipped; `@objectstack/cli` unit layer 4951 passed (the integration layer is declared to CI). - `typecheck` exit 0 on `@objectstack/metadata-protocol` and `@objectstack/objectql`. - First run, before the fixture fixes: metadata-protocol 30 failed in 4 files and objectql 6 failed in 1 file, every one a double or boot that never installed the package its rows were bound to (listed under Fixtures above). </details> ## Acceptance notes - Studio still shows "Package not found" for a phantom id (#26708), so the way out (delete, discard, or create the package) is a REST or SDK act, not a Studio one. - A phantom id that the install door refuses as a package id (for example one with underscores) cannot be brought back by creating the package; its rows can still be deleted and discarded. - The draft address is now read for every type's draft save naming no package (it was read for `object` only): two `sys_metadata` reads, active and draft, the reads `resolveWriteHead` makes for the same save. ## Verification - Live red/green on the real REST door, same probe on both trees (`pnpm dev -- -p PORT --database file:DB`, signed in as the seeded dev admin). The database is a copy of one snapshot written by `main` before #26920 (`7eb613c3`): five dashboards saved with `?package=com.example.nosuch` (active, active + draft, draft only), all 200 there. <details><summary>Red on the base (`25a697ec`, #26920 merged) vs green on this branch</summary> | Request (no package installed under `com.example.nosuch`) | Base | This branch | |---|---|---| | `PUT /meta/dashboard/ghost_dash_a?package=com.example.nosuch` (control: #26708's rule) | 422 | 422, unchanged sentence | | `PUT /meta/dashboard/ghost_dash_d` (active row bound to the id) | 200, receipt `package=com.example.nosuch` | 422 `WRITABLE_PACKAGE_REQUIRED`; `GET` still serves label `Ghost D` | | `PUT /meta/dashboard/ghost_dash_a?mode=draft` | 200, draft stored bound to the id | 422; `GET ?state=draft` answers 404 `NO_DRAFT` (nothing written) | | `POST /meta/dashboard/ghost_dash_e/publish?package=com.example.nosuch` | 200, draft served live | 422; `GET` still serves the old active label `Ghost E` | | `POST /meta/dashboard/ghost_dash_c/publish` (lone phantom draft, no package named) | 200, served live | 422; `GET` 404 (still not live) | | `POST /packages/com.example.nosuch/publish-drafts` (control) | 404 | 404 | | Control: `POST /packages` `app.probe1` (201), save into it, save naming none, draft, publish `?package=app.probe1` | 200 ×4, rows bound to `app.probe1` | the same | | Control: package-less `plain_dash` save, draft, publish | 200 ×3 | the same | | `DELETE /meta/dashboard/ghost_dash_b?package=com.example.nosuch` (the way out) | 200, then `GET` 404 | the same | Inherited-save sentence as served (length 423 for these names, under the 500-character client bound): "Cannot save dashboard 'ghost_dash_d' without naming a package: its stored rows are bound to package 'com.example.nosuch', which is not installed in this deployment, and this save would extend that binding. Create a package with that id first, or remove the rows bound to it (a delete naming that package; discard-drafts for drafts) and save again, into an installed writable package or with no package. Nothing was written." </details> - Measure-first, on the pre-#26920 tree with the same snapshot: `DELETE /meta/dashboard/ghost_dash_a` (no package) 200 and `GET` 404; `DELETE …ghost_dash_d?package=com.example.nosuch` 200; `DELETE …ghost_dash_b?state=draft&package=com.example.nosuch` 200 and the draft read 404 `NO_DRAFT`; `POST /packages` with id `com.example.nosuch` 201, then `PUT /meta/dashboard/ghost_dash_e` naming none 200 with the row still bound to that (now installed) id. The two doors this PR closes are not on any of those paths. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head `6135db9b`: 101 commands, each run with its exit code captured before any pipe, all 101 exit 0. `--ran` reconciliation: "101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN". <details><summary>The two first-run reds and what cleared them</summary> - `check:engine-double-contract` exit 1: the new test file's engine double answers `delete`, `findOne` and `update` the ledger did not record ("New pinned coverage is GOOD … Run `--write`"). Recorded with `node scripts/check-engine-double-contract.mjs --write` (commit `6135db9b`), then exit 0. - `check-plugin-teardown-shape.mjs --self-test` exit 3: the shallow clone lacked its positive-control commit; after `git fetch --depth=1 origin 621a487…`, exit 0. Not a finding. - The ledger commit moved the derived set by 8 families (`scripts/**` now in the change set); those 8 were run on `6135db9b`, all exit 0. </details> - Full dogfood once (`pnpm --filter @objectstack/dogfood test` under the verify lock, after a full build, 116/116 tasks) on `16162380` (this branch after merging `main` at `3230bdbf`): `VERDICT command-exit 0`, Test Files 346 passed / 3 skipped, Tests 2794 passed / 20 skipped. The only commit after it, `6135db9b`, adds three rows to `scripts/engine-double-contract.pinned.json`, which no dogfood test reads. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Root ESLint `--no-inline-config` on the 7 touched TS files: exit 0.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7f76b6 commit 6845035

10 files changed

Lines changed: 473 additions & 34 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
**BREAKING** fix(metadata)!: the per-item publish and a save naming no package no longer extend a row bound to a package that is not installed
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) What is refused is a REQUEST, never a stored shape: a per-item publish whose draft would go live in a package id no installed package has, and a save naming no package whose address the item's stored rows give in such an id. No metadata document changes meaning or validity, no key or value is retired, and rows already stored under such an id still read, list, delete and discard exactly as before, so there is nothing for `objectstack migrate meta` to rewrite. The remedy is a different request (create the package first, or remove the rows bound to it and save again), not a conversion between two metadata versions. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no spec or Zod face moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->
11+
12+
A save or a `publish-drafts` naming a package id that is not installed is already refused. Two doors still extended rows bound to such an id, stored before that refusal existed: a save naming no package kept the binding the item's rows gave it, and `POST /api/v1/meta/:type/:name/publish` (with `?package=ID` or without) put the draft live, owned by a package that cannot be uninstalled or exported.
13+
14+
- **`@objectstack/metadata-protocol`**: both doors now ask the same named-base rule the save door asks. `PUT /api/v1/meta/:type/:name` naming no package, draft or active, on an item whose stored rows are bound to an id the package registry does not hold, is refused `422 WRITABLE_PACKAGE_REQUIRED` with the id as `packageId`, and nothing is written. The per-item publish whose draft would go live in such an id, named or given by the item's rows, is refused the same way, and nothing is published. Rows bound to an installed package, rows bound to no package, and saves naming a package are unchanged. Stored rows are not migrated or deleted.
15+
16+
**What to do.** For an item bound to a package id that is not installed: create a package with that id (`POST /api/v1/packages`), after which its rows save and publish again; or remove its rows bound to that id (`DELETE /api/v1/meta/:type/:name?package=ID` for the active row, `POST /api/v1/packages/ID/discard-drafts` for drafts) and save the item again into an installed package or with no package.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,13 @@ first step of any Studio/API authoring action is to target a *writable* package
9595
to pick or create a writable base first. A save naming a package id this deployment
9696
has not installed is refused the same way, draft or active and for every type
9797
(`422 WRITABLE_PACKAGE_REQUIRED`, nothing written), and
98-
`POST /packages/:id/publish-drafts` on such an id answers `404`, publishing nothing;
99-
drafts stored under one before that rule can still be removed with
100-
`POST /packages/:id/discard-drafts`. An in-place edit of an item the read-only
98+
`POST /packages/:id/publish-drafts` on such an id answers `404`, publishing nothing.
99+
Rows stored under such an id before that rule are not extended either: a save
100+
naming no package, whose address those rows give, and the per-item
101+
`POST /meta/:type/:name/publish` of such a draft are refused with the same `422`.
102+
They can still be removed — `DELETE /meta/:type/:name?package=:id` for the active
103+
row, `POST /packages/:id/discard-drafts` for drafts — or brought back by creating a
104+
package with that id. An in-place edit of an item the read-only
101105
package ships is refused too; for a flow, an action or a permission set the refusal
102106
names that type's sanctioned path instead (see [ADR-0126](#packaged-flows-actions-and-permission-sets-adr-0126)). New objects, fields, views, and
103107
flows are namespaced into that package — which is exactly what `os package publish`

‎packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ function makeKernel(opts: {
237237
registerItem: () => {}, registerObject: () => {}, listItems: () => [],
238238
getItem: () => undefined, getArtifactItem: () => undefined,
239239
removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {},
240+
// [#26921] The package the seeded rows are bound to is installed:
241+
// a promotion into it, or a save naming none over its rows, asks
242+
// the named-base rule, which refuses a package the registry lacks.
243+
getPackage: (id: string) => (id === PKG ? { manifest: { id } } : undefined),
240244
},
241245
};
242246

‎packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ function makeKernel(opts: {
224224
registerItem: () => {}, registerObject: () => {}, listItems: () => [],
225225
getItem: () => undefined, getArtifactItem: () => undefined,
226226
removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {},
227+
// [#26921] The package the seeded rows are bound to is installed:
228+
// a promotion into it, or a save naming none over its rows, asks
229+
// the named-base rule, which refuses a package the registry lacks.
230+
getPackage: (id: string) => (id === PKG ? { manifest: { id } } : undefined),
227231
},
228232
};
229233

‎packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ function makeKernel(opts: { seed?: Row[] } = {}) {
282282
registerItem: () => {}, registerObject: () => {}, listItems: () => [],
283283
getItem: () => undefined, getArtifactItem: () => undefined,
284284
removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {},
285+
// [#26921] The package the seeded rows are bound to is installed:
286+
// a promotion into it, or a save naming none over its rows, asks
287+
// the named-base rule, which refuses a package the registry lacks.
288+
getPackage: (id: string) => (id === PKG ? { manifest: { id } } : undefined),
285289
},
286290
};
287291

0 commit comments

Comments
 (0)