Repository navigation
Commit 699ebd8
fix(metadata-protocol)!: the metadata save and publish doors refuse a select field bound to a picklist no loaded package declares (#29956)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_
Fixes #29898
Clause-②: no (narrowing) — the metadata save door (draft and live), the
per-item and package publish, and the restore doors refuse an `object`
or `object_extension` whose field names a picklist no loaded package
declares, a body they accepted before. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: no move — `skills/**` teaches no picklist REFERENCE verdict
(the two mentions are "a picklist-bound field's options are never
patchable" and the stack-key list), and `content/docs/**` carries no
severity table for this reference; the prescription ships in the refusal
text, the changeset's FROM → TO and the new upgrade-guide entry.
## 维护者速读
- 改了什么:对象(以及对象扩展)里 select / multiselect 字段绑定到一个"本环境没有任何包声明"的 picklist
时,元数据保存门、单项与整包发布、以及回滚/还原门现在直接 422
拒绝并点名字段与那个清单;以前存下来并发布成功,字段被服务成"零选项",之后对该字段的每次写入都被拒。
- 为什么改:启动与后装载早就拒绝这个形状,`os validate` / `os build`
也拒绝栈内字段,只有运行时写入这道门放行,作者(或经 API 写元数据的 agent)拼错一个清单名就得到一张永远存不进数据的字段,且
`_diagnostics.valid: true` 什么都不提示。
- 风险与回滚:收窄写入门。对象已持有的绑定不重判(再保存照旧成功),`migrate-stored`
自愈不判;此前存下的草稿会在发布时被拒(它本来就让该字段写入全部失败)。回滚即 revert 本 PR,无数据迁移。
- 先看:`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(新判定
`unknownPicklistBindingSaveRefusal`,"已持有绑定"豁免是风险
hunk)、`packages/metadata-protocol/src/protocol.ts`(保存门与发布晋升的接入点、方法文档)、`packages/cli/src/utils/picklist-references.boot-parity.test.ts`(两道门判定一致的
pin)。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/meta-picklist-binding-door.test.ts`,预期 5 项全过:未声明清单在对象与
`.custom` 扩展上各被拒一次,依赖包的清单、`.custom` 基包的清单、同环境另一个已安装包的清单各自保存+发布成功并被服务上选项。
- 需要你做的:无,席位落地(本 PR 动了 `packages/spec` 的迁移条目,按席位安排走
`CONTRACT_REVIEW_TIER` 复核)。
## What changes
The dangling-reference refusal family at the metadata save door gains
its picklist member.
- **New judge** — `unknownPicklistBindingSaveRefusal` in
`packages/metadata-protocol/src/protocol-save-door-refusals.ts`, beside
`unknownGrantObjectSaveRefusal` (#28990) and
`unknownDatasourceSaveRefusal` (#26724), whose shape it follows. It
judges an `object` and an `object_extension` body (an extension's fields
join the object it extends, so the object it names is `extend`), reads
which picklists the body's fields name through the boot audit's own walk
(`collectPicklistReferences`,
`@objectstack/metadata/picklist-references`; the `os validate` door
walks the parsed artifact with its own reader and agrees with this door
by pin, not by sharing the walk), and refuses `422 INVALID_METADATA`
with one finding per unknown binding at `fields.FIELD.picklist`, naming
the field and the list.
- **What counts as declared** — the engine registry's own
`resolvePicklistOptions`, the read that SERVES a bound field's options.
So the package's own list, a dependency's, the base package's of a
`.custom` pair and any other package installed here all resolve, and the
judge cannot offer a field options the write door then refuses. A
registry that cannot answer the question refuses nothing (the posture
`unknownGrantObjectSaveRefusal` takes for `getObject`).
- **Where it is asked** — `saveMetaItem` after the schema gate, in draft
and in publish mode (a `migrate-stored` re-save excepted, the platform
healing its own storage); `promoteDraftForPublish` on the draft body, so
a draft stored before this rule, or one whose list went between its save
and its publish, is refused and a batch publish aborts on it; and the
restore doors through `refuseRestoredBodyTheSaveDoorRefuses`.
- **Not judged twice** — a binding the object's current definition
already holds on that field is not this write's: a re-save, a stored-row
migration or a restore that keeps it passes, and a list that has gone
since is not the re-saving author's to answer for. There is no pending
arm, because `picklist` is not runtime-creatable (`allowRuntimeCreate:
false`), so no draft of a batch can declare one.
- **`packages/spec`** — one ADR-0087 semantic upgrade-guide entry,
`picklist-binding-unknown-refused`. ⛔ No schema shape under
`packages/spec/src/**` is touched (the order's stop condition):
`FieldSchema.picklist` is unchanged, and the refusal is a door judgement
against the registry, not a Zod refinement — the verdict depends on
which packages are installed, which a schema cannot see.
- **`packages/cli`** — one source comment in the `os validate` / `os
build` picklist judge. It said the runtime metadata write path "is not
judged here" as a stated bound; it now names the door that judges it
against the engine's registry.
### The measured surface shift (the dispatch's route was lint; the door
is metadata-protocol)
The order and the claim named "the author-time dangling-reference gate
in `packages/lint`". Measured at `21510bba`, that is not where the
precedent lives, and the seat accepted the shift before this PR was
opened:
- the lookup/reference arm of the reference-integrity suite
(`validateObjectReferences`, which refuses a field's dangling
`reference`) declares `runtimeTypes: ['flow', 'dataset']`, so it never
judges an `object` write at the save door — a crossing is a
`runtimeTypes` decision of its own, and an advisory-tier fence sits over
that door (#4716);
- the author-time picklist judge already exists and is deliberately NOT
a lint registry rule: `packages/cli/src/utils/picklist-references.ts`
says why in its header (the verdict depends on WHICH package declares
the reference, and a registry rule is handed one flattened stack), and
it names the runtime write path as its unjudged bound;
- the save-door family that DOES refuse a dangling reference at the
runtime doors is `protocol-save-door-refusals.ts`. #29790's
`objectRegistrationRefusal` is the registry pre-flight at the same door,
which picklist resolution is not one of.
So the fix joins that family, and no `packages/lint` file is touched (no
overlap with #29926, which edits
`packages/lint/src/validate-expressions.ts`).
## Tests
Every reading below is from `objectstack-ai/objectstack` on this branch,
each heavy command under `scripts/pm/os-verify-lock.sh` (slot
`dev-29898`). The suites ran at `f04c956c`. Since then `d1809dd5` adds
only `scripts/engine-double-contract.pinned.json` (the ledger row for
the new test's engine double, written by the gate's own `--write`), and
`8d7a02d5` corrects two source comments and adds the changeset's
`Skills-docs:` line; no package or dogfood test reads either.
| suite | reading |
|:--|:--|
| the new protocol pins + the restore-door family | `Test Files 2 passed
(2)`, `Tests 63 passed (63)`, lock `VERDICT command-exit 0` |
| `@objectstack/metadata-protocol` whole package | `Test Files 312
passed | 4 skipped (316)`, `Tests 22339 passed | 26 skipped (22365)` |
| the real-`SchemaRegistry` door test (`@objectstack/runtime`) | `Test
Files 1 passed (1)`, `Tests 5 passed (5)` |
| the two-judge agreement pin (`@objectstack/cli`, `unit`) | `Test Files
1 passed (1)`, `Tests 4 passed (4)` |
| `@objectstack/cli` `unit` layer | `Tests 5279 passed | 29 skipped
(5308)`; the two files that read the CLI's own `dist`
(`published-subpath-console`, `published-subpath-hook-body`) re-run
after `pnpm --filter @objectstack/cli build`: `Test Files 2 passed (2)`,
`Tests 29 passed (29)`. `integration` layer declared to CI (no
integration-tier file touched) |
| typecheck | `@objectstack/metadata-protocol` and `@objectstack/cli`
`tsc --noEmit` clean (lock `VERDICT command-exit 0`) |
| full dogfood battery at `f04c956c` (narrowing arm) | `Test Files 414
passed | 3 skipped (417)`, `Tests 3427 passed | 28 skipped (3455)`, lock
`VERDICT command-exit 0`, held 37m13s |
New and changed pins:
-
`packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts`
(new) — through `saveMetaItem` (draft into a package, draft naming none,
a publish save, a publish save under `force`), `publishMetaItem` and
`publishPackageDrafts`: the envelope (`code`, `status`), the
`issues[].path` set and the first sentence of each finding, nothing
stored and nothing promoted; a `multiselect` judged as a `select`; an
`object_extension` judged under the object it extends; a list that goes
between a draft's save and its publish; the `migrate-stored` exemption;
and the controls — a resolved list, no binding at all, a held binding on
a list gone since (with the two re-binding cases that ARE refused), and
a registry that cannot resolve a list.
-
`packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts`
— one more member of the family it exists for, at both restore doors
(`rollbackMetaItem`, `revertCommit`) with their controls.
- `packages/runtime/src/domains/meta-picklist-binding-door.test.ts`
(new) — the real `SchemaRegistry` behind a real `ObjectQL` +
`better-sqlite3`, a code base package, its `.custom` pair, a second
installed list package and a runtime package: the two refusals, and the
three "declared" controls each saved, published AND served with the
list's options.
- `packages/cli/src/utils/picklist-references.boot-parity.test.ts` — a
second top-level `describe` in the harness that already pins this door
against the boot (AGENTS.md: a new pin for a component with a shared
harness file goes into that file). It answers the seat's ask directly:
the two judges cannot share one judgement (this door reads the parsed
artifact and weighs outside dependencies; the save door asks the live
registry), so the agreement is pinned — on a list a SIBLING package of
the same artifact owns, where they could disagree, and on a name nothing
declares, where both refuse the same field (the paths are rooted in
their own subjects, so the artifact-rooted path ends with the
body-rooted one).
<details>
<summary>Ablation — each door's negative pins really fail without the
fix (three legs)</summary>
**Leg 1, the save door.** One leg, through `node
scripts/ablation-replace.mjs` in wrap mode: the save door's call site in
`saveMetaItem` (the four lines that ask
`unknownPicklistBindingSaveRefusal` and throw) replaced by a marker,
anchor `x1 -> x0`, blob `becf5c5fcc0e -> 19085994bfa4`. The suite
resolves `./protocol.js` from source, so no build sits between the
mutation and the run.
```
× a draft save into a package is refused 422 at `fields.<field>.picklist` …
× a draft save naming no package is refused 422 …
× a publish save is refused 422 …
× a publish save under `force` is refused 422 …
× a `multiselect` is judged as a `select` is …
× an `object_extension` field bound to an unknown list is refused the same way …
× an author re-saving the same draft is still refused
× a re-save that keeps a binding the object already holds on a list gone since …
Test Files 1 failed (1)
Tests 8 failed | 7 passed (15)
```
The 7 that stay green are the publish-door pins (their own call site in
`promoteDraftForPublish` is untouched) and the controls — the direction
expected. Restore: `blob after restore becf5c5 == blob at HEAD`,
`git diff HEAD` empty.
**Leg 2, the publish door.** The `throw` of the call site in
`promoteDraftForPublish` replaced by a marker, anchor `x1 -> x0`, blob
`becf5c5fcc0e -> f15546e7b85a`, same suite:
```
× publishMetaItem refuses it, and the draft stays a draft
× publishPackageDrafts answers `refused`, names it, and promotes nothing of the batch
× a list that goes between the draft save and its publish: the per-item publish is refused
× `migrate-stored` is not judged by this door, and the draft's publish is still refused
Test Files 1 failed (1)
Tests 4 failed | 11 passed (15)
```
Exactly the three publish-door pins and the `migrate-stored` "publish is
still refused" assertion; the save-door pins and the controls stay
green. Restore: `blob after restore becf5c5 == blob at HEAD`, `git
diff HEAD` empty.
**Leg 3, the restore doors.** The `throw` in
`refuseRestoredBodyTheSaveDoorRefuses` (`protocol-authoring-gate.ts`)
replaced by a marker, anchor `x1 -> x0`, blob `a114a4c2f1ff ->
e7d365211ff2`, `protocol.restore-doors-save-refusal-family.test.ts`:
```
× an object restored with a field bound to a picklist no loaded package declares now (unknownPicklistBindingSaveRefusal) > rollbackMetaItem: answers INVALID_METADATA / 422; nothing is stored
× … > revertCommit: reports INVALID_METADATA in failed[]; nothing is stored
Test Files 1 failed (1)
Tests 2 failed | 46 passed (48)
```
Exactly the two picklist refusal cases of `pinBothDoors`; every other
family member and the controls stay green. Restore: `blob after restore
a114a4c == blob at HEAD`, `git diff HEAD` empty.
</details>
## Acceptance notes
- The boot's own refusal (`describeUnresolvedPicklistReferences`) is
unchanged and still the authority for a package's own objects at load;
this door is the authoring half it named as missing.
- One deliberate divergence between the two judges, pre-existing on the
CLI side: a list owned by a package that is installed in the same
environment but is neither the stack itself, a sibling in the same
artifact nor a declared dependency. The save door accepts it (the
registry resolves it, and it is what the field will be served). `os
validate` cannot see the environment, so it judges the stack alone, as
it did before this PR. The new door never refuses a list the registry
serves, so it cannot be stricter than the serve path; the runtime test
pins that case as a control.
- A copy is judged like any other write. `duplicatePackage` and the
unmanaged import copy items through `saveMetaItem` in publish mode, and
the copy target holds nothing yet, so a source object holding a binding
on a list that is gone since is now refused when copied (the import
rolls back). That is the same property `unknownGrantObjectSaveRefusal`
(#28990) already gives those two paths, and it is right on the merits:
the copy would be served with no options.
- A tenant-authored body was already excluded from the boot audit on
purpose (a stored row must not be able to fail a boot). That stays true:
this judge refuses the WRITE, so the row never exists to be judged at
boot.
## Verification
- `PUT /api/v1/meta/object/x_probe?mode=draft&package=com.qa.x` with
`fields.t = { type: 'select', picklist: 'nope_list' }` → `422
INVALID_METADATA`, finding at `fields.t.picklist` naming the field and
`nope_list`; nothing stored, so `POST
/api/v1/packages/com.qa.x/publish-drafts` has nothing of it to publish
(the card's steps 2–5).
- The same PUT with a picklist a package installed here declares →
`200`, `publish-drafts` → `published`, `GET /api/v1/meta/object/x_probe`
serves `t` with that list's options, and `POST /api/v1/data/x_probe`
with one of its values → `200`.
<details>
<summary>Gate sweep — <code>node scripts/pm/dispatch-gates.mjs
--ran</code>: 95 derived families accounted for, 94 run, 1
NOT-MEASURED</summary>
Every family `dispatch-gates.mjs --commands` derives for this diff was
run locally and recorded; `--ran` answers `✓ 95 derived famil(ies)
accounted for — 94 run, 1 NOT-MEASURED`.
- 94 exit `0`. Five of them were green only on a second run, each for a
reason outside the diff, with the fix noted:
`check:engine-double-contract` (the new test's engine double had no
ledger row; added by the gate's own `--write`, commit `d1809dd5`),
`check-plugin-teardown-shape --self-test` (needs full history; green
after unshallowing the clone), `check:dts-closure` (spec `dist` was
rebuilt at the same moment by the dogfood build; green on rerun,
225/225), `check:i18n-coverage` and `check:type-check-debt` (build
prerequisites and the shared lock; green on rerun, `426 baselined, none
new` and `0 raw tsc error(s)`).
- NOT MEASURED: `check:dual-build-cjs-loads` exits `3`, the prerequisite
refusal, because packages this diff does not touch have no local `dist`.
CI's build lane runs it.
- Repo-wide `pnpm lint`: exit `0`, run locally after the PR opened.
- Not run locally, left to CI: the `@objectstack/cli` `integration`
vitest layer (no integration-tier file is touched).
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzis5HWU3u6jXfEfM17
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzis5HWU3u6jXfEfM17 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3b8c724 commit 699ebd8
11 files changed
Lines changed: 926 additions & 8 deletions
File tree
- .changeset
- packages
- cli/src/utils
- metadata-protocol/src
- runtime/src/domains
- spec/src/migrations/entries/semantic
- 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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
27 | 29 | | |
28 | 30 | | |
29 | 31 | | |
| |||
59 | 61 | | |
60 | 62 | | |
61 | 63 | | |
62 | | - | |
63 | | - | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
64 | 73 | | |
65 | 74 | | |
66 | 75 | | |
| 76 | + | |
67 | 77 | | |
68 | 78 | | |
69 | 79 | | |
70 | 80 | | |
| 81 | + | |
71 | 82 | | |
72 | 83 | | |
73 | 84 | | |
74 | 85 | | |
75 | | - | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
76 | 90 | | |
77 | 91 | | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
78 | 95 | | |
79 | 96 | | |
80 | 97 | | |
| |||
90 | 107 | | |
91 | 108 | | |
92 | 109 | | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
86 | | - | |
87 | | - | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
88 | 90 | | |
89 | 91 | | |
90 | 92 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
| 95 | + | |
95 | 96 | | |
96 | 97 | | |
97 | 98 | | |
| |||
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
129 | | - | |
| 130 | + | |
130 | 131 | | |
131 | 132 | | |
132 | 133 | | |
| |||
145 | 146 | | |
146 | 147 | | |
147 | 148 | | |
| 149 | + | |
| 150 | + | |
148 | 151 | | |
149 | 152 | | |
150 | 153 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
69 | 75 | | |
70 | 76 | | |
71 | 77 | | |
| |||
640 | 646 | | |
641 | 647 | | |
642 | 648 | | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
643 | 708 | | |
644 | 709 | | |
645 | 710 | | |
| |||
0 commit comments