Repository navigation
Commit 6845035
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
File tree
- .changeset
- content/docs/concepts
- packages
- metadata-protocol/src
- objectql/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
99 | | - | |
100 | | - | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
101 | 105 | | |
102 | 106 | | |
103 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
240 | 244 | | |
241 | 245 | | |
242 | 246 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
224 | 224 | | |
225 | 225 | | |
226 | 226 | | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
227 | 231 | | |
228 | 232 | | |
229 | 233 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
282 | 282 | | |
283 | 283 | | |
284 | 284 | | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
285 | 289 | | |
286 | 290 | | |
287 | 291 | | |
| |||
0 commit comments