Skip to content

Commit 699ebd8

Browse files
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
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/spec': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
fix(metadata-protocol)!: the metadata save and publish doors refuse a select field bound to a picklist no loaded package declares, instead of storing it and then refusing every write to the field (#29898)
8+
9+
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.
10+
Metadata-protocol: narrowed
11+
Skills-docs: no move — `skills/**` teaches no picklist reference verdict and `content/docs/**` carries no severity table for it; the prescription ships in the refusal text, the FROM → TO below and the upgrade-guide entry.
12+
13+
<!-- adr-0087: registered picklist-binding-unknown-refused -->
14+
15+
**BREAKING** accept-set narrowing at the metadata save and publish doors: `PUT /api/v1/meta/object/NAME` and `PUT /api/v1/meta/object_extension/NAME` (draft and live), `POST /api/v1/packages/PKG/publish-drafts`, the per-item publish, and the rollback / revert restore doors.
16+
17+
- **Before.** A select (or multiselect) field with `picklist: 'nope_list'`, a name no loaded package declares, saved `200` with its draft reading `_diagnostics.valid: true`, and `publish-drafts` answered `outcome: published`. The field was then served with no options, and every create or update that set it answered `400 VALIDATION_FAILED` ("takes its values from picklist \"nope_list\", which no loaded package declares"). The boot and a late install already refuse the same shape for a package's own objects, and `os validate` / `os build` refuse it for a stack's own fields.
18+
- **Now.** The doors refuse it `422 INVALID_METADATA`, one finding per unknown binding at `fields.FIELD.picklist`, naming the field and the list. A list is declared when the engine's registry resolves it, the same read that serves the field's options: the package's own, a dependency's, the base package's of a `.custom` pair, or any other package installed here.
19+
- **Unchanged.** A binding the object already holds is not judged again on a re-save, so an object published before this release keeps saving; the stored-metadata healing pass (`os migrate meta --stored`) is not judged. A draft stored before this release is judged when it is published.
20+
- **`@objectstack/spec`.** The upgrade guide entry `picklist-binding-unknown-refused` (a semantic migration: no automatic rewrite, because whether the author meant another list, one still to be declared or inline options is a judgement about the field).
21+
- **`@objectstack/cli`.** A source comment in the `os validate` picklist judge no longer says the runtime write path is unjudged.
22+
23+
**FROM → TO.** `fields.t: { type: 'select', picklist: 'nope_list' }` → `picklist: 'NAME'` of a list a package installed here declares, or declare `nope_list` in a code package (a `*.picklist.ts` file, or `defineStack({ picklists })`), or `options: [...]` in place of `picklist`.

‎packages/cli/src/utils/picklist-references.boot-parity.test.ts‎

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import { afterEach, describe, expect, it } from 'vitest';
2323
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
2424
import { ObjectQLPlugin } from '@objectstack/objectql';
2525

26-
import { judgePicklistReferences, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js';
26+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
27+
28+
import { judgePicklistReferences, PICKLIST_REFERENCE_UNKNOWN, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js';
2729

2830
type AnyRec = Record<string, unknown>;
2931

@@ -59,22 +61,37 @@ afterEach(async () => {
5961
}
6062
});
6163

62-
/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */
63-
async function bootRefusal(stack: AnyRec): Promise<(Error & { code?: string }) | undefined> {
64+
/**
65+
* Boot the stack through the `manifest` service. Answers the boot's refusal (or
66+
* `undefined`) and the engine it registered into, so a pin can ask the
67+
* REGISTRY what the boot made of the stack.
68+
*/
69+
async function bootStack(stack: AnyRec): Promise<{
70+
refusal?: Error & { code?: string };
71+
engine?: { registry: unknown };
72+
}> {
6473
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
6574
kernels.push(kernel);
6675
await kernel.use(new ObjectQLPlugin());
76+
let engine: { registry: unknown } | undefined;
6777
await kernel.use({
6878
name: 'test.parity.app',
6979
dependencies: ['com.objectstack.engine.objectql'],
7080
init: async (ctx: PluginContext) => {
81+
engine = ctx.getService<{ registry: unknown }>('objectql');
7182
await ctx.getService<{ register(m: unknown): unknown }>('manifest')
7283
.register({ ...(stack.manifest as AnyRec), ...stack });
7384
},
7485
} as Plugin);
75-
return kernel.bootstrap().then(() => undefined, (e: Error & { code?: string }) => e);
86+
return kernel.bootstrap().then(
87+
() => ({ engine }),
88+
(e: Error & { code?: string }) => ({ refusal: e, engine }),
89+
);
7690
}
7791

92+
/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */
93+
const bootRefusal = async (stack: AnyRec) => (await bootStack(stack)).refusal;
94+
7895
describe('picklist-residual-unreferenced — the author-time door and the boot agree', () => {
7996
it.each(Object.keys(STACKS))('%s', async (label) => {
8097
const stack = STACKS[label];
@@ -90,3 +107,86 @@ describe('picklist-residual-unreferenced — the author-time door and the boot a
90107
expect(boot?.message).toBe(`${door[0].message} ${door[0].hint}`);
91108
});
92109
});
110+
111+
/**
112+
* [#29898] The second parity, on the same stacks: `picklist-reference-unknown`
113+
* (this door, over the PARSED artifact) and the metadata save door's
114+
* `unknownPicklistBindingSaveRefusal` (`@objectstack/metadata-protocol`, over
115+
* the BOOTED registry) must call the same name declared.
116+
*
117+
* They cannot share one judgement the way the residual pair above does: this
118+
* door resolves a reference against the artifact's own `picklists` and weighs
119+
* the declaring package's outside dependencies, while the save door asks the
120+
* live registry (`resolvePicklistOptions`, the read that serves the field's
121+
* options) because a runtime write is judged against the packages INSTALLED
122+
* here. So the agreement is pinned rather than inherited — and pinned where
123+
* the two can disagree: a list a SIBLING package of the same artifact owns,
124+
* which this door resolves under ADR-0130 D4 and the save door must resolve
125+
* because the boot registered it.
126+
*
127+
* Asked of the save door's judge directly, over the real registry the boot
128+
* filled. `readMetaItem` is stubbed to "no stored row", so the case is a FIRST
129+
* write of the binding; the held-binding exemption, the other doors and the
130+
* envelope are pinned in
131+
* `packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts`,
132+
* and the real-store controls (a dependency's list, the base's list of a
133+
* `.custom` pair, another installed package's) in
134+
* `packages/runtime/src/domains/meta-picklist-binding-door.test.ts`.
135+
*/
136+
describe('[#29898] picklist-reference-unknown — the author-time door and the metadata save door agree', () => {
137+
/** The object body a case writes: one select field bound to `picklist`. */
138+
const boundCase = (picklist: string) =>
139+
caseObj({ subject: { type: 'text', label: 'Subject' }, status: { type: 'select', label: 'Status', picklist } });
140+
141+
/** One artifact: the core body carries the object, the app body declares `parity_status`. */
142+
const artifactWith = (picklist: string): AnyRec => ({
143+
manifest: manifest(STACK_ID),
144+
packages: [
145+
{ manifest: { ...manifest('com.example.parity.core'), objects: [boundCase(picklist)] } },
146+
{ manifest: { ...manifest('com.example.parity.app'), picklists: [status] } },
147+
],
148+
});
149+
150+
/**
151+
* The save door's judge, over the registry a boot of the DECLARING artifact
152+
* filled — the live deployment an author then writes into. ⛔ Not a boot of
153+
* the typo artifact: that is the shape the boot already refuses (the pin
154+
* above), which is why the save door is the door this card is about.
155+
*/
156+
const saveDoorRefusal = async (body: AnyRec) => {
157+
const { refusal, engine } = await bootStack(artifactWith('parity_status'));
158+
expect(refusal, String(refusal?.message)).toBeUndefined();
159+
const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map()) as unknown as {
160+
readMetaItem: unknown;
161+
unknownPicklistBindingSaveRefusal(type: string, item: unknown, name: string): Promise<{ issues?: Array<{ path: string }> } | undefined>;
162+
};
163+
protocol.readMetaItem = async () => undefined;
164+
return protocol.unknownPicklistBindingSaveRefusal('object', body, String(body.name));
165+
};
166+
167+
it('a list a SIBLING package of the same artifact declares: both doors accept it', async () => {
168+
const stack = artifactWith('parity_status');
169+
170+
const door = judgePicklistReferences(stack);
171+
const saveDoor = await saveDoorRefusal(boundCase('parity_status'));
172+
173+
expect(door.refusals.filter((r) => r.rule === PICKLIST_REFERENCE_UNKNOWN)).toEqual([]);
174+
expect(door.notices).toEqual([]);
175+
expect(saveDoor, JSON.stringify(saveDoor?.issues)).toBeUndefined();
176+
});
177+
178+
// The two paths are rooted in their own SUBJECTS — the artifact for this
179+
// door, the one body for the save door — so the field is the tail of the
180+
// artifact-rooted path, never the whole of it.
181+
it('a name no package of the artifact declares: both doors refuse it, at the same field', async () => {
182+
const stack = artifactWith('parity_typo');
183+
184+
const door = judgePicklistReferences(stack).refusals.filter((r) => r.rule === PICKLIST_REFERENCE_UNKNOWN);
185+
const saveDoor = await saveDoorRefusal(boundCase('parity_typo'));
186+
187+
expect(door).toHaveLength(1);
188+
expect(saveDoor?.issues?.map((i) => i.path)).toEqual(['fields.status.picklist']);
189+
expect(door[0].path, door[0].path).toBe('packages[0].manifest.objects[0].fields.status.picklist');
190+
expect(door[0].path?.endsWith(saveDoor!.issues![0].path)).toBe(true);
191+
});
192+
});

‎packages/cli/src/utils/picklist-references.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@
8383
* package reading, the same class as `findViewContainerNameRefusals`, and both
8484
* doors call it (`validate.ts` step 2d, `compile.ts` step 3a-bis).
8585
*
86-
* ⚠️ Bound, stated rather than hidden: the runtime metadata write path (a field
87-
* saved through Studio or REST `/meta`) is not judged here.
86+
* The runtime metadata write path (a field saved through Studio or REST
87+
* `/meta`) is not judged here: the metadata save and publish doors judge it
88+
* against the engine's registry (`unknownPicklistBindingSaveRefusal`,
89+
* `@objectstack/metadata-protocol`).
8890
*
8991
* Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what
9092
* `os build` serializes.

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export interface AuthoringGateHost {
9292
tenancyPostureInForce: ObjectStackProtocolImplementation['tenancyPostureInForce'];
9393
unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal'];
9494
unknownGrantObjectSaveRefusal: ObjectStackProtocolImplementation['unknownGrantObjectSaveRefusal'];
95+
unknownPicklistBindingSaveRefusal: ObjectStackProtocolImplementation['unknownPicklistBindingSaveRefusal'];
9596
seedExternalIdSaveRefusal: ObjectStackProtocolImplementation['seedExternalIdSaveRefusal'];
9697
}
9798

@@ -126,7 +127,7 @@ export function restoredBodyWriter(
126127

127128
/** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */
128129
export async function refuseRestoredBodyTheSaveDoorRefuses(
129-
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'seedExternalIdSaveRefusal' | 'unknownDatasourceSaveRefusal' | 'unknownGrantObjectSaveRefusal'>,
130+
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'seedExternalIdSaveRefusal' | 'unknownDatasourceSaveRefusal' | 'unknownGrantObjectSaveRefusal' | 'unknownPicklistBindingSaveRefusal'>,
130131
type: string,
131132
name: string,
132133
body: unknown,
@@ -145,6 +146,8 @@ export async function refuseRestoredBodyTheSaveDoorRefuses(
145146
if (datasourceRefusal) throw datasourceRefusal;
146147
const grantRefusal = await this.unknownGrantObjectSaveRefusal(type, body, name);
147148
if (grantRefusal) throw grantRefusal;
149+
const picklistRefusal = await this.unknownPicklistBindingSaveRefusal(type, body, name);
150+
if (picklistRefusal) throw picklistRefusal;
148151
const seedKeyRefusal = await this.seedExternalIdSaveRefusal(type, body, name);
149152
if (seedKeyRefusal) throw seedKeyRefusal;
150153
const repo = this.getOverlayRepo();

‎packages/metadata-protocol/src/protocol-save-door-refusals.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from '
6666
import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
6767
import { defaultRecordPageObject } from '@objectstack/spec/ui';
6868
import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
69+
// [#29898] The one walk of which picklists an object body's fields name — the
70+
// boot audit's. (The `os validate` door reads the parsed artifact with its own
71+
// walk and agrees with this door by pin, not by sharing it.) ⛔ Never a second
72+
// walk here.
73+
import { collectPicklistReferences } from '@objectstack/metadata/picklist-references';
74+
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
6975

7076
/** The lock family `body` carries: each member with a value, and nothing else. */
7177
function lockFamilyOf(body: unknown): Record<string, unknown> {
@@ -640,6 +646,65 @@ export async function unknownGrantObjectSaveRefusal(
640646
return err;
641647
}
642648

649+
/**
650+
* The body of `ObjectStackProtocolImplementation#unknownPicklistBindingSaveRefusal`;
651+
* the method's docblock there is its contract. Written here beside
652+
* {@link unknownGrantObjectSaveRefusal}, whose shape it follows: the engine's
653+
* registry, then the definition the object already holds.
654+
*/
655+
export async function unknownPicklistBindingSaveRefusal(
656+
this: Pick<SaveDoorRefusalHost, 'engine' | 'readMetaItem'>,
657+
singularType: string,
658+
item: unknown,
659+
name: string,
660+
writeFace?: MetadataWriteFace,
661+
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
662+
if (singularType !== 'object' && singularType !== OBJECT_EXTENSION_TYPE) return undefined;
663+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
664+
// An extension's fields join the object it extends; its row is stored
665+
// under that object's name.
666+
const extend = (item as { extend?: unknown }).extend;
667+
const object = singularType === OBJECT_EXTENSION_TYPE && typeof extend === 'string' && extend !== '' ? extend : name;
668+
let unknown = collectPicklistReferences(object, item, undefined);
669+
if (unknown.length === 0) return undefined;
670+
// The registry is the authority that SERVES a bound field's options
671+
// (`resolvePicklistOptions`): a list it resolves is one a loaded package
672+
// declares — the package's own, a dependency's, the base package's of a
673+
// `.custom` pair, or any other package installed here. A registry that
674+
// cannot resolve a list cannot say a name is unknown, so nothing is
675+
// refused on it.
676+
const registry = this.engine?.registry as { resolvePicklistOptions?: unknown } | undefined;
677+
const resolvePicklistOptions = registry?.resolvePicklistOptions;
678+
if (typeof resolvePicklistOptions !== 'function') return undefined;
679+
unknown = unknown.filter((ref) => resolvePicklistOptions.call(registry, ref.picklist) === undefined);
680+
if (unknown.length === 0) return undefined;
681+
// A binding the object already holds is not this save's: a re-save, a
682+
// stored-row migration or a restore that keeps it is judged at the save
683+
// that introduced it, and a list that has gone since is not the
684+
// re-saving author's to answer for.
685+
const current = (await this.readMetaItem({ type: 'object', name: object }))?.item;
686+
const bindingOf = (ref: { field: string; picklist: string }) => JSON.stringify([ref.field, ref.picklist]);
687+
const held = new Set(collectPicklistReferences(object, current, undefined).map(bindingOf));
688+
unknown = unknown.filter((ref) => !held.has(bindingOf(ref)));
689+
if (unknown.length === 0) return undefined;
690+
const issues: MetadataIssueEntry[] = unknown.map((ref) => ({
691+
path: `fields.${ref.field}.picklist`,
692+
message: `Field '${ref.object}.${ref.field}' is bound to picklist '${ref.picklist}', which no loaded package `
693+
+ 'declares, so the field would be served with no options and every write that sets it would be refused. '
694+
+ 'A field bound to a picklist takes its options from that list and has none of its own. Correct the name '
695+
+ 'to a picklist a package installed here declares, declare the picklist (a `*.picklist.ts` file, or '
696+
+ '`defineStack({ picklists })`) in a package installed here, or give the field its own `options` instead.',
697+
code: 'custom',
698+
}));
699+
const err = new Error(
700+
`${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`,
701+
) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] };
702+
err.code = 'INVALID_METADATA';
703+
err.status = 422;
704+
err.issues = issues;
705+
return err;
706+
}
707+
643708
/** The body of `ObjectStackProtocolImplementation#objectsBoundExternallyTo`; the method's docblock there is its contract. */
644709
export async function objectsBoundExternallyTo(
645710
this: Pick<SaveDoorRefusalHost, 'engine' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'storedOverlayEntries'>,

0 commit comments

Comments
 (0)