diff --git a/.changeset/29898-picklist-binding-unknown-refused.md b/.changeset/29898-picklist-binding-unknown-refused.md new file mode 100644 index 00000000000..07e2bb8fb19 --- /dev/null +++ b/.changeset/29898-picklist-binding-unknown-refused.md @@ -0,0 +1,23 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +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) + +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 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. + + + +**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. + +- **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. +- **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. +- **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. +- **`@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). +- **`@objectstack/cli`.** A source comment in the `os validate` picklist judge no longer says the runtime write path is unjudged. + +**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`. diff --git a/packages/cli/src/utils/picklist-references.boot-parity.test.ts b/packages/cli/src/utils/picklist-references.boot-parity.test.ts index 3002f5863e2..2109d6933ee 100644 --- a/packages/cli/src/utils/picklist-references.boot-parity.test.ts +++ b/packages/cli/src/utils/picklist-references.boot-parity.test.ts @@ -23,7 +23,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; -import { judgePicklistReferences, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; + +import { judgePicklistReferences, PICKLIST_REFERENCE_UNKNOWN, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js'; type AnyRec = Record; @@ -59,22 +61,37 @@ afterEach(async () => { } }); -/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */ -async function bootRefusal(stack: AnyRec): Promise<(Error & { code?: string }) | undefined> { +/** + * Boot the stack through the `manifest` service. Answers the boot's refusal (or + * `undefined`) and the engine it registered into, so a pin can ask the + * REGISTRY what the boot made of the stack. + */ +async function bootStack(stack: AnyRec): Promise<{ + refusal?: Error & { code?: string }; + engine?: { registry: unknown }; +}> { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); kernels.push(kernel); await kernel.use(new ObjectQLPlugin()); + let engine: { registry: unknown } | undefined; await kernel.use({ name: 'test.parity.app', dependencies: ['com.objectstack.engine.objectql'], init: async (ctx: PluginContext) => { + engine = ctx.getService<{ registry: unknown }>('objectql'); await ctx.getService<{ register(m: unknown): unknown }>('manifest') .register({ ...(stack.manifest as AnyRec), ...stack }); }, } as Plugin); - return kernel.bootstrap().then(() => undefined, (e: Error & { code?: string }) => e); + return kernel.bootstrap().then( + () => ({ engine }), + (e: Error & { code?: string }) => ({ refusal: e, engine }), + ); } +/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */ +const bootRefusal = async (stack: AnyRec) => (await bootStack(stack)).refusal; + describe('picklist-residual-unreferenced — the author-time door and the boot agree', () => { it.each(Object.keys(STACKS))('%s', async (label) => { const stack = STACKS[label]; @@ -90,3 +107,86 @@ describe('picklist-residual-unreferenced — the author-time door and the boot a expect(boot?.message).toBe(`${door[0].message} ${door[0].hint}`); }); }); + +/** + * [#29898] The second parity, on the same stacks: `picklist-reference-unknown` + * (this door, over the PARSED artifact) and the metadata save door's + * `unknownPicklistBindingSaveRefusal` (`@objectstack/metadata-protocol`, over + * the BOOTED registry) must call the same name declared. + * + * They cannot share one judgement the way the residual pair above does: this + * door resolves a reference against the artifact's own `picklists` and weighs + * the declaring package's outside dependencies, while the save door asks the + * live registry (`resolvePicklistOptions`, the read that serves the field's + * options) because a runtime write is judged against the packages INSTALLED + * here. So the agreement is pinned rather than inherited — and pinned where + * the two can disagree: a list a SIBLING package of the same artifact owns, + * which this door resolves under ADR-0130 D4 and the save door must resolve + * because the boot registered it. + * + * Asked of the save door's judge directly, over the real registry the boot + * filled. `readMetaItem` is stubbed to "no stored row", so the case is a FIRST + * write of the binding; the held-binding exemption, the other doors and the + * envelope are pinned in + * `packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts`, + * and the real-store controls (a dependency's list, the base's list of a + * `.custom` pair, another installed package's) in + * `packages/runtime/src/domains/meta-picklist-binding-door.test.ts`. + */ +describe('[#29898] picklist-reference-unknown — the author-time door and the metadata save door agree', () => { + /** The object body a case writes: one select field bound to `picklist`. */ + const boundCase = (picklist: string) => + caseObj({ subject: { type: 'text', label: 'Subject' }, status: { type: 'select', label: 'Status', picklist } }); + + /** One artifact: the core body carries the object, the app body declares `parity_status`. */ + const artifactWith = (picklist: string): AnyRec => ({ + manifest: manifest(STACK_ID), + packages: [ + { manifest: { ...manifest('com.example.parity.core'), objects: [boundCase(picklist)] } }, + { manifest: { ...manifest('com.example.parity.app'), picklists: [status] } }, + ], + }); + + /** + * The save door's judge, over the registry a boot of the DECLARING artifact + * filled — the live deployment an author then writes into. ⛔ Not a boot of + * the typo artifact: that is the shape the boot already refuses (the pin + * above), which is why the save door is the door this card is about. + */ + const saveDoorRefusal = async (body: AnyRec) => { + const { refusal, engine } = await bootStack(artifactWith('parity_status')); + expect(refusal, String(refusal?.message)).toBeUndefined(); + const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map()) as unknown as { + readMetaItem: unknown; + unknownPicklistBindingSaveRefusal(type: string, item: unknown, name: string): Promise<{ issues?: Array<{ path: string }> } | undefined>; + }; + protocol.readMetaItem = async () => undefined; + return protocol.unknownPicklistBindingSaveRefusal('object', body, String(body.name)); + }; + + it('a list a SIBLING package of the same artifact declares: both doors accept it', async () => { + const stack = artifactWith('parity_status'); + + const door = judgePicklistReferences(stack); + const saveDoor = await saveDoorRefusal(boundCase('parity_status')); + + expect(door.refusals.filter((r) => r.rule === PICKLIST_REFERENCE_UNKNOWN)).toEqual([]); + expect(door.notices).toEqual([]); + expect(saveDoor, JSON.stringify(saveDoor?.issues)).toBeUndefined(); + }); + + // The two paths are rooted in their own SUBJECTS — the artifact for this + // door, the one body for the save door — so the field is the tail of the + // artifact-rooted path, never the whole of it. + it('a name no package of the artifact declares: both doors refuse it, at the same field', async () => { + const stack = artifactWith('parity_typo'); + + const door = judgePicklistReferences(stack).refusals.filter((r) => r.rule === PICKLIST_REFERENCE_UNKNOWN); + const saveDoor = await saveDoorRefusal(boundCase('parity_typo')); + + expect(door).toHaveLength(1); + expect(saveDoor?.issues?.map((i) => i.path)).toEqual(['fields.status.picklist']); + expect(door[0].path, door[0].path).toBe('packages[0].manifest.objects[0].fields.status.picklist'); + expect(door[0].path?.endsWith(saveDoor!.issues![0].path)).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/picklist-references.ts b/packages/cli/src/utils/picklist-references.ts index 247149e5f1e..931bc8f2979 100644 --- a/packages/cli/src/utils/picklist-references.ts +++ b/packages/cli/src/utils/picklist-references.ts @@ -83,8 +83,10 @@ * package reading, the same class as `findViewContainerNameRefusals`, and both * doors call it (`validate.ts` step 2d, `compile.ts` step 3a-bis). * - * ⚠️ Bound, stated rather than hidden: the runtime metadata write path (a field - * saved through Studio or REST `/meta`) is not judged here. + * The runtime metadata write path (a field saved through Studio or REST + * `/meta`) is not judged here: the metadata save and publish doors judge it + * against the engine's registry (`unknownPicklistBindingSaveRefusal`, + * `@objectstack/metadata-protocol`). * * Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what * `os build` serializes. diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts index 6fb69ae67c7..a114a4c2f1f 100644 --- a/packages/metadata-protocol/src/protocol-authoring-gate.ts +++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts @@ -92,6 +92,7 @@ export interface AuthoringGateHost { tenancyPostureInForce: ObjectStackProtocolImplementation['tenancyPostureInForce']; unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal']; unknownGrantObjectSaveRefusal: ObjectStackProtocolImplementation['unknownGrantObjectSaveRefusal']; + unknownPicklistBindingSaveRefusal: ObjectStackProtocolImplementation['unknownPicklistBindingSaveRefusal']; seedExternalIdSaveRefusal: ObjectStackProtocolImplementation['seedExternalIdSaveRefusal']; } @@ -126,7 +127,7 @@ export function restoredBodyWriter( /** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */ export async function refuseRestoredBodyTheSaveDoorRefuses( - this: Pick, + this: Pick, type: string, name: string, body: unknown, @@ -145,6 +146,8 @@ export async function refuseRestoredBodyTheSaveDoorRefuses( if (datasourceRefusal) throw datasourceRefusal; const grantRefusal = await this.unknownGrantObjectSaveRefusal(type, body, name); if (grantRefusal) throw grantRefusal; + const picklistRefusal = await this.unknownPicklistBindingSaveRefusal(type, body, name); + if (picklistRefusal) throw picklistRefusal; const seedKeyRefusal = await this.seedExternalIdSaveRefusal(type, body, name); if (seedKeyRefusal) throw seedKeyRefusal; const repo = this.getOverlayRepo(); diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts index 74739c969b9..edf2c3fe986 100644 --- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts +++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts @@ -66,6 +66,12 @@ import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from ' import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; +// [#29898] The one walk of which picklists an object body's fields name — the +// boot audit's. (The `os validate` door reads the parsed artifact with its own +// walk and agrees with this door by pin, not by sharing it.) ⛔ Never a second +// walk here. +import { collectPicklistReferences } from '@objectstack/metadata/picklist-references'; +import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js'; /** The lock family `body` carries: each member with a value, and nothing else. */ function lockFamilyOf(body: unknown): Record { @@ -640,6 +646,65 @@ export async function unknownGrantObjectSaveRefusal( return err; } +/** + * The body of `ObjectStackProtocolImplementation#unknownPicklistBindingSaveRefusal`; + * the method's docblock there is its contract. Written here beside + * {@link unknownGrantObjectSaveRefusal}, whose shape it follows: the engine's + * registry, then the definition the object already holds. + */ +export async function unknownPicklistBindingSaveRefusal( + this: Pick, + singularType: string, + item: unknown, + name: string, + writeFace?: MetadataWriteFace, +): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { + if (singularType !== 'object' && singularType !== OBJECT_EXTENSION_TYPE) return undefined; + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + // An extension's fields join the object it extends; its row is stored + // under that object's name. + const extend = (item as { extend?: unknown }).extend; + const object = singularType === OBJECT_EXTENSION_TYPE && typeof extend === 'string' && extend !== '' ? extend : name; + let unknown = collectPicklistReferences(object, item, undefined); + if (unknown.length === 0) return undefined; + // The registry is the authority that SERVES a bound field's options + // (`resolvePicklistOptions`): a list it resolves is one a loaded package + // declares — the package's own, a dependency's, the base package's of a + // `.custom` pair, or any other package installed here. A registry that + // cannot resolve a list cannot say a name is unknown, so nothing is + // refused on it. + const registry = this.engine?.registry as { resolvePicklistOptions?: unknown } | undefined; + const resolvePicklistOptions = registry?.resolvePicklistOptions; + if (typeof resolvePicklistOptions !== 'function') return undefined; + unknown = unknown.filter((ref) => resolvePicklistOptions.call(registry, ref.picklist) === undefined); + if (unknown.length === 0) return undefined; + // A binding the object already holds is not this save's: a re-save, a + // stored-row migration or a restore that keeps it is judged at the save + // that introduced it, and a list that has gone since is not the + // re-saving author's to answer for. + const current = (await this.readMetaItem({ type: 'object', name: object }))?.item; + const bindingOf = (ref: { field: string; picklist: string }) => JSON.stringify([ref.field, ref.picklist]); + const held = new Set(collectPicklistReferences(object, current, undefined).map(bindingOf)); + unknown = unknown.filter((ref) => !held.has(bindingOf(ref))); + if (unknown.length === 0) return undefined; + const issues: MetadataIssueEntry[] = unknown.map((ref) => ({ + path: `fields.${ref.field}.picklist`, + message: `Field '${ref.object}.${ref.field}' is bound to picklist '${ref.picklist}', which no loaded package ` + + 'declares, so the field would be served with no options and every write that sets it would be refused. ' + + 'A field bound to a picklist takes its options from that list and has none of its own. Correct the name ' + + 'to a picklist a package installed here declares, declare the picklist (a `*.picklist.ts` file, or ' + + '`defineStack({ picklists })`) in a package installed here, or give the field its own `options` instead.', + code: 'custom', + })); + const err = new Error( + `${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`, + ) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.issues = issues; + return err; +} + /** The body of `ObjectStackProtocolImplementation#objectsBoundExternallyTo`; the method's docblock there is its contract. */ export async function objectsBoundExternallyTo( this: Pick, diff --git a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts index bee8b5e3206..e269a17b9d2 100644 --- a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts +++ b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts @@ -9,7 +9,8 @@ * default record page of an object, an `external` object binding on a managed * datasource (both halves of the pair), an object `datasource` that names no * datasource (#26724), a permission-set grant on an object that does not - * exist (#28990), a keyed seed whose `externalId` names no field of its + * exist (#28990), a field bound to a picklist no loaded package declares + * (#29898), a keyed seed whose `externalId` names no field of its * object (#28952), a `runAs: 'system'` flow an anonymous * endpoint names, the shared runtime authoring rules, and the authoring gate a * domain plugin registers. Measured on `origin/main` `5801a4f2c` before this @@ -99,6 +100,8 @@ function makeStubEngine() { const drivers = new Set(); /** The engine's registered objects, by name (empty unless a case stages one). */ const objects = new Map(); + /** [#29898] The picklists the registry resolves, by name (empty unless a case stages one). */ + const picklists = new Set(); let nextId = 0; let commit: unknown = null; @@ -193,6 +196,7 @@ function makeStubEngine() { getItem: (type: string, name: string) => registered.get(type)?.get(name), getAllObjects: () => [], getObject: (name: string) => objects.get(name), + resolvePicklistOptions: (name: string) => (picklists.has(name) ? [{ value: 'gold', label: 'Gold' }] : undefined), getPackage: () => undefined, getArtifactItem: () => undefined, isPackageDisabled: () => false, @@ -205,6 +209,7 @@ function makeStubEngine() { registered, drivers, objects, + picklists, serveCommit: (c: unknown) => { commit = c; }, }; } @@ -529,6 +534,31 @@ pinBothDoors({ }, }); +pinBothDoors({ + title: 'an object restored with a field bound to a picklist no loaded package declares now (unknownPicklistBindingSaveRefusal)', + type: 'object', + name: 'qa_deal', + code: 'INVALID_METADATA', + status: 422, + async stage(h) { + h.picklists.add('qa_tier'); + const deal = (fields: Record, label = 'Deal') => ({ + name: 'qa_deal', label, sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' }, ...fields }, + }); + await h.protocol.saveMetaItem({ type: 'object', name: 'qa_deal', item: deal({ tier: { type: 'select', label: 'Tier', picklist: 'qa_tier' } }) }); + // v2 drops the field (a destructive change, so `force`). + await h.protocol.saveMetaItem({ type: 'object', name: 'qa_deal', item: deal({}), force: true }); + await h.protocol.saveMetaItem({ type: 'object', name: 'qa_deal', item: deal({}, 'Deal v3') }); + // The list v1 was bound to is gone, and the object holds no binding to it now. + h.picklists.delete('qa_tier'); + return { refused: 1, accepted: 2 }; + }, + expectDetail: (error) => { + expect(error.issues).toEqual([expect.objectContaining({ path: 'fields.tier.picklist', code: 'custom' })]); + expect(error.issues[0].message.startsWith("Field 'qa_deal.tier' is bound to picklist 'qa_tier', which no loaded package declares")).toBe(true); + }, +}); + pinBothDoors({ title: 'a seed restored keyed on a field its object no longer has (seedExternalIdSaveRefusal)', type: 'seed', diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 51ba056d7d8..becf5c5fcc0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -325,6 +325,7 @@ import { seedExternalIdSaveRefusal, unknownDatasourceSaveRefusal, unknownGrantObjectSaveRefusal, + unknownPicklistBindingSaveRefusal, } from './protocol-save-door-refusals.js'; // [#25467 cut 7] Package install and update. import { @@ -1608,6 +1609,9 @@ export class ObjectStackProtocolImplementation implements * - [#28990] a permission set restored with a grant on an object that * does not exist here, when the set does not hold that grant now * ({@link unknownGrantObjectSaveRefusal}); + * - [#29898] an object (or object extension) restored with a field bound + * to a picklist no loaded package declares, when the object does not + * hold that binding now ({@link unknownPicklistBindingSaveRefusal}); * - [#27818] a datasource restored with an `external.credentialsRef` the * active row it is restored into does not hold now * ({@link datasourceCredentialsRefSaveRefusal}, in its restore words); @@ -1671,6 +1675,7 @@ export class ObjectStackProtocolImplementation implements runAuthoringGate: (...a) => this.runAuthoringGate(...a), unknownDatasourceSaveRefusal: (...a) => this.unknownDatasourceSaveRefusal(...a), unknownGrantObjectSaveRefusal: (...a) => this.unknownGrantObjectSaveRefusal(...a), + unknownPicklistBindingSaveRefusal: (...a) => this.unknownPicklistBindingSaveRefusal(...a), seedExternalIdSaveRefusal: (...a) => this.seedExternalIdSaveRefusal(...a), }; return refuseRestoredBodyTheSaveDoorRefuses.call(host, type, name, body, binding, source); @@ -12871,6 +12876,54 @@ export class ObjectStackProtocolImplementation implements return unknownGrantObjectSaveRefusal.call(host, singularType, item, name, pending, writeFace, door); } + /** + * [#29898] The save doors refuse an `object` (or an `object_extension`) + * whose field is bound to a picklist no loaded package declares. Before + * this, such a body was stored and published (200, its draft reading + * `_diagnostics.valid: true`), the field was served with no options, and + * the record validator refused every value of it — the shape the boot and + * the late-install load already refuse + * (`describeUnresolvedPicklistReferences`) and `os validate` / `os build` + * refuse for a stack's own fields (`picklist-reference-unknown`); this is + * the same judgement at the runtime door. + * + * A binding is known when any of these holds, and the refusal fires on + * none: + * - the engine's registry resolves the list (`resolvePicklistOptions`, + * the read that serves the field's options): a list the package + * itself, a dependency, the base package of a `.custom` pair or any + * other package installed here declares. A registry that cannot + * resolve a list refuses nothing; + * - the object's current definition already binds that field to that + * list: the binding is not this write's. A re-save or a restore that + * keeps it is not judged, and neither is a list that has gone since. + * + * No pending arm: a picklist is not runtime-creatable, so no draft of + * this batch can declare one. + * + * Asked in draft and in publish mode by {@link saveMetaItem} after the + * schema gate (except a `migrate-stored` re-save, the platform healing + * its own storage), again by {@link promoteDraftForPublish} on the draft + * body (a draft stored before this refusal, or whose list went between + * its save and its publish), and by the restore doors + * ({@link refuseRestoredBodyTheSaveDoorRefuses}). Answered as the schema + * gate answers: `422 INVALID_METADATA`, one finding per unknown binding + * at `fields..picklist`, naming the field and the list. + */ + private unknownPicklistBindingSaveRefusal( + singularType: string, + item: unknown, + name: string, + writeFace?: MetadataWriteFace, + ): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { + const readEngine = () => this.engine; + const host: ThisParameterType = { + get engine() { return readEngine(); }, + readMetaItem: (...a) => this.readMetaItem(...a), + }; + return unknownPicklistBindingSaveRefusal.call(host, singularType, item, name, writeFace); + } + /** * [#28952] The refusal of a `seed` whose `externalId` names no field of * the seed's object: a load in a keyed mode (`upsert`, `update`, @@ -14658,6 +14711,20 @@ export class ObjectStackProtocolImplementation implements if (grantRefusal) throw grantRefusal; } + // [#29898] An `object` (or an `object_extension`) whose field is bound + // to a picklist no loaded package declares is refused, in draft and in + // publish mode, after the schema and before the authoring gate and + // every write: stored, the field is served with no options and every + // write that sets it is refused. A `migrate-stored` re-save rewrites a + // row the store already holds and is not judged; a draft-only row is + // judged at its publish. See {@link unknownPicklistBindingSaveRefusal}. + if (writeSource !== 'migrate-stored') { + const picklistRefusal = await this.unknownPicklistBindingSaveRefusal( + singularType, request.item, request.name, request.writeFace, + ); + if (picklistRefusal) throw picklistRefusal; + } + // [#28952] A keyed `seed` whose `externalId` names no field of its // object is refused, in draft and in publish mode, after the schema and // before the authoring gate and every write: every load of it would @@ -16626,6 +16693,16 @@ export class ObjectStackProtocolImplementation implements ); if (grantRefusal) throw grantRefusal; } + // [#29898] …and an object (or object-extension) draft whose field is + // bound to a picklist no loaded package declares by the time it is + // published: a draft stored before this refusal, or one whose list + // went between its save and its publish. Judged on the draft body, + // before `repo.promoteDraft` writes, so a batch publish aborts on it + // too. See {@link unknownPicklistBindingSaveRefusal}. + if (draftForGate) { + const picklistRefusal = await this.unknownPicklistBindingSaveRefusal(singularType, draftForGate.body, request.name); + if (picklistRefusal) throw picklistRefusal; + } // [#28952] …and a seed draft whose `externalId` names no field of its // object by the time it is published: a draft saved before this // refusal, one whose object was still a draft at its save, or one whose diff --git a/packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts b/packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts new file mode 100644 index 00000000000..3f7e7664514 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29898] The metadata save doors refuse an `object` (or an + * `object_extension`) whose field is bound to a picklist no loaded package + * declares. + * + * Measured on `origin/main` before this file: an object saved with a select + * field bound to `picklist: 'nope_list'` was stored in draft and in publish + * mode, and `publishPackageDrafts` answered `outcome: 'published'`. The field + * was then served with no options and the record validator refused every value + * of it. The boot and the late-install load refuse the same shape + * (`describeUnresolvedPicklistReferences`), and `os validate` refuses it for a + * stack's own fields. + * + * Pinned here, through `saveMetaItem`, `publishMetaItem` and + * `publishPackageDrafts` (the restore doors are pinned in + * `protocol.restore-doors-save-refusal-family.test.ts`; the controls over a + * real `SchemaRegistry` — a dependency's list, the base's list on a `.custom` + * pair's extension, another installed package's list — in + * `packages/runtime/src/domains/meta-picklist-binding-door.test.ts`): + * - the refusal: `422 INVALID_METADATA`, one finding per unknown binding at + * `fields..picklist` naming the field and the list, nothing stored + * (save) and nothing promoted (publish of a draft stored before the rule; + * the batch aborts); + * - a `multiselect` is judged as a `select` is; + * - controls: a list the registry resolves (saved, published and promoted), + * no binding at all, a re-save that keeps a binding the object already + * holds on a list gone since, the stored-metadata healing pass over a + * draft-only row, and a registry that cannot resolve a list. + * + * Each refusal is asserted by its code, its status, its path and the first + * sentence of its finding, spelled out here. + * + * Harness: the stateful `sys_metadata` double of + * `protocol.unknown-grant-object-refusal.test.ts`, kept local, with a registry + * that answers `resolvePicklistOptions` over a mutable set of lists. + */ +// Pays the lint root's first load at collection, never inside a clocked case. +import './lint-root-preload.testkit.js'; +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const PKG = 'app.picklistprobe'; + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** Top-level eq + `$or` + `$in` + explicit-NULL, the subset these paths emit. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Array>).some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + const actual = (r as unknown as Record)[k]; + if (v !== null && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes(actual)) return false; + continue; + } + if (v === null ? actual !== null && actual !== undefined : actual !== v) return false; + } + return true; +} + +function makeSession(opts: { picklists?: string[]; resolvesPicklists?: boolean; objects?: string[] } = {}) { + const rows = new Map(); + const history: Array> = []; + const picklists = new Set(opts.picklists ?? []); + const objects = new Set(opts.objects ?? []); + let nextId = 0; + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + const registry: any = { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + getAllObjects: () => [], + // The objects an `object_extension` may extend. + getObject: (name: string) => (objects.has(name) ? { name } : undefined), + // An installed authored base that declares no namespace. + getPackage: (id: string) => (id === PKG ? { manifest: { id } } : undefined), + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }; + if (opts.resolvesPicklists !== false) { + registry.resolvePicklistOptions = (name: string) => + (picklists.has(name) ? [{ value: 'gold', label: 'Gold' }] : undefined); + } + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table === 'sys_metadata_history') { + return history.find((h) => Object.entries(o.where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`history double: unsupported operator ${k}`); + return v === undefined || h[k] === v; + })) ?? null; + } + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o?.where ?? {})); + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + async insert(table: string, data: Record) { + nextId += 1; + if (table === 'sys_metadata_history') { + history.push({ ...data, id: `h_${nextId}` }); + return { id: `h_${nextId}` }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, o: { where: Record }) { + assertEngineDeleteDispatch(o); + const found = findRow(o.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + registry, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + return { protocol, rows, picklists }; +} + +const refusalOf = (run: Promise) => run.then(() => null, (e: any) => e); + +/** The stored rows of one object, as `state|package|field:picklist,…`. */ +const stored = (rows: Map, name: string) => + [...rows.values()] + .filter((r) => r.type === 'object' && r.name === name) + .map((r) => { + const fields = JSON.parse(r.metadata).fields ?? {}; + const bound = Object.entries(fields) + .filter(([, f]: [string, any]) => typeof f?.picklist === 'string') + .map(([k, f]: [string, any]) => `${k}:${f.picklist}`); + return `${r.state}|${r.package_id ?? '-'}|${bound.join(',')}`; + }) + .sort(); + +/** A schema-valid object body with one select field per `[field, picklist]` binding. */ +const object = (name: string, bindings: Array<[string, string]>, extra: Record = {}) => ({ + name, + label: 'Probe', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + ...Object.fromEntries(bindings.map(([field, picklist]) => [field, { type: 'select', label: 'Tier', picklist }])), + }, + ...extra, +}); + +/** Plant an object row as a row stored before the rule sits at rest. */ +function plant(rows: Map, body: Record, state: 'draft' | 'active', packageId: string | null = PKG) { + const data = { + type: 'object', name: String(body.name), organization_id: null, package_id: packageId, + state, metadata: JSON.stringify(body), + }; + rows.set(keyOf(data), { ...data, id: `planted_${state}_${String(body.name)}` }); +} + +/** The finding's first sentence, spelled out. */ +const unknownFinding = (objectName: string, field: string, picklist: string) => + `Field '${objectName}.${field}' is bound to picklist '${picklist}', which no loaded package declares, ` + + 'so the field would be served with no options and every write that sets it would be refused.'; + +const expectRefusal = (err: any, objectName: string, bindings: Array<[string, string]>, type = 'object') => { + expect({ code: err?.code, status: err?.status }, String(err?.stack ?? err)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message).startsWith(`${type}/${objectName} failed spec validation: `), String(err.message)).toBe(true); + expect(err.issues.map((i: any) => i.path)).toEqual(bindings.map(([field]) => `fields.${field}.picklist`)); + for (const [index, [field, picklist]] of bindings.entries()) { + expect(err.issues[index].code).toBe('custom'); + expect(String(err.issues[index].message).startsWith(unknownFinding(objectName, field, picklist)), err.issues[index].message).toBe(true); + } +}; + +describe('[#29898] the object save door — a field bound to a picklist no loaded package declares', () => { + it.each([ + ['a draft save into a package', { mode: 'draft', packageId: PKG }], + ['a draft save naming no package', { mode: 'draft' }], + ['a publish save', {}], + ['a publish save under `force`', { force: true }], + ])('%s is refused 422 at `fields..picklist`, naming the field and the list, and nothing is stored', async (_label, saveOpts) => { + const { protocol, rows } = makeSession({ picklists: ['qa_tier'] }); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'object', name: 'qa_probe', item: object('qa_probe', [['tier', 'qa_tier'], ['t', 'nope_list']]), ...saveOpts, + })); + + expectRefusal(err, 'qa_probe', [['t', 'nope_list']]); + expect(stored(rows, 'qa_probe')).toEqual([]); + }); + + it('a `multiselect` is judged as a `select` is; one finding per unknown binding, in field order', async () => { + const { protocol } = makeSession(); + const item = object('qa_probe', [['t', 'nope_list']]); + (item.fields as Record).tags = { type: 'multiselect', label: 'Tags', picklist: 'nope_tags' }; + + const err = await refusalOf(protocol.saveMetaItem({ type: 'object', name: 'qa_probe', item, mode: 'draft' })); + + expectRefusal(err, 'qa_probe', [['t', 'nope_list'], ['tags', 'nope_tags']]); + }); + + it('an `object_extension` field bound to an unknown list is refused the same way, naming the object it extends', async () => { + const { protocol } = makeSession({ objects: ['qa_account'] }); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'object_extension', name: 'qa_account', mode: 'draft', packageId: PKG, + item: { extend: 'qa_account', fields: { tier: { type: 'select', label: 'Tier', picklist: 'nope_list' } } }, + })); + + expectRefusal(err, 'qa_account', [['tier', 'nope_list']], 'object_extension'); + }); +}); + +describe('[#29898] the publish doors — an object draft stored before the rule', () => { + it('publishMetaItem refuses it, and the draft stays a draft', async () => { + const { protocol, rows } = makeSession(); + plant(rows, object('qa_probe', [['t', 'nope_list']]), 'draft', null); + + const err = await refusalOf(protocol.publishMetaItem({ type: 'object', name: 'qa_probe' })); + + expectRefusal(err, 'qa_probe', [['t', 'nope_list']]); + expect(stored(rows, 'qa_probe')).toEqual(['draft|-|t:nope_list']); + }); + + it('publishPackageDrafts answers `refused`, names it, and promotes nothing of the batch', async () => { + const { protocol, rows } = makeSession({ picklists: ['qa_tier'] }); + await protocol.saveMetaItem({ type: 'object', name: 'qa_good', item: object('qa_good', [['tier', 'qa_tier']]), mode: 'draft', packageId: PKG }); + plant(rows, object('qa_probe', [['t', 'nope_list']]), 'draft'); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ outcome: 'refused', success: false, publishedCount: 0, published: [] }); + const causal = res.failed.find((f: any) => f.name === 'qa_probe'); + expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'object', code: 'INVALID_METADATA' }); + expect(JSON.stringify(causal)).toContain(unknownFinding('qa_probe', 't', 'nope_list')); + expect(stored(rows, 'qa_probe')).toEqual([`draft|${PKG}|t:nope_list`]); + }); + + it('a list that goes between the draft save and its publish: the per-item publish is refused', async () => { + const { protocol, rows, picklists } = makeSession({ picklists: ['qa_tier'] }); + await protocol.saveMetaItem({ type: 'object', name: 'qa_probe', item: object('qa_probe', [['tier', 'qa_tier']]), mode: 'draft' }); + picklists.delete('qa_tier'); + + const err = await refusalOf(protocol.publishMetaItem({ type: 'object', name: 'qa_probe' })); + + expectRefusal(err, 'qa_probe', [['tier', 'qa_tier']]); + expect(stored(rows, 'qa_probe')).toEqual(['draft|-|tier:qa_tier']); + }); +}); + +describe('[#29898] the stored-metadata healing pass — a draft-only object stored before the rule', () => { + it('`migrate-stored` is not judged by this door, and the draft\'s publish is still refused', async () => { + const { protocol, rows } = makeSession(); + plant(rows, object('qa_probe', [['t', 'nope_list']]), 'draft', null); + + await protocol.migrateStoredMetadata({ apply: true, types: ['object'] }); + + expect(stored(rows, 'qa_probe')).toEqual(['draft|-|t:nope_list']); + const err = await refusalOf(protocol.publishMetaItem({ type: 'object', name: 'qa_probe' })); + expectRefusal(err, 'qa_probe', [['t', 'nope_list']]); + }); + + it('an author re-saving the same draft is still refused', async () => { + const { protocol, rows } = makeSession(); + plant(rows, object('qa_probe', [['t', 'nope_list']]), 'draft', null); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'object', name: 'qa_probe', item: object('qa_probe', [['t', 'nope_list']], { label: 'Probe v2' }), mode: 'draft', + })); + + expectRefusal(err, 'qa_probe', [['t', 'nope_list']]); + }); +}); + +describe('[#29898] controls — a binding the door does not refuse', () => { + it.each([ + ['a list the registry resolves', [['tier', 'qa_tier']] as Array<[string, string]>], + ['no picklist binding at all', [] as Array<[string, string]>], + ])('%s: saves as a draft, publishes, and saves live', async (_label, bindings) => { + const { protocol, rows } = makeSession({ picklists: ['qa_tier'] }); + await protocol.saveMetaItem({ type: 'object', name: 'qa_ok', item: object('qa_ok', bindings), mode: 'draft', packageId: PKG }); + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res.outcome, JSON.stringify(res.failed)).toBe('published'); + expect((await protocol.saveMetaItem({ type: 'object', name: 'qa_ok2', item: object('qa_ok2', bindings) })).success).toBe(true); + expect(stored(rows, 'qa_ok')).toEqual([`active|${PKG}|${bindings.map(([f, p]) => `${f}:${p}`).join(',')}`]); + }); + + it('a re-save that keeps a binding the object already holds on a list gone since (a row stored before the rule)', async () => { + const session = makeSession({ picklists: ['qa_tier'] }); + plant(session.rows, object('qa_old', [['t', 'qa_gone']]), 'active', null); + + const result = await session.protocol.saveMetaItem({ + type: 'object', name: 'qa_old', item: object('qa_old', [['t', 'qa_gone'], ['tier', 'qa_tier']], { label: 'Renamed' }), + }); + + expect(result.success).toBe(true); + // …while binding another field to an unknown list is this save's binding, and refused, + // and so is re-binding the held field to a different unknown list. + const err = await refusalOf(session.protocol.saveMetaItem({ + type: 'object', name: 'qa_old', item: object('qa_old', [['t', 'qa_gone'], ['tier', 'qa_tier'], ['u', 'qa_typo']]), + })); + expectRefusal(err, 'qa_old', [['u', 'qa_typo']]); + const rebound = await refusalOf(session.protocol.saveMetaItem({ + type: 'object', name: 'qa_old', item: object('qa_old', [['t', 'qa_typo'], ['tier', 'qa_tier']]), force: true, + })); + expectRefusal(rebound, 'qa_old', [['t', 'qa_typo']]); + }); + + it('a registry that cannot resolve a list refuses nothing', async () => { + const { protocol, rows } = makeSession({ resolvesPicklists: false }); + expect((await protocol.saveMetaItem({ type: 'object', name: 'qa_probe', item: object('qa_probe', [['t', 'nope_list']]) })).success).toBe(true); + expect(stored(rows, 'qa_probe')).toEqual(['active|-|t:nope_list']); + }); +}); diff --git a/packages/runtime/src/domains/meta-picklist-binding-door.test.ts b/packages/runtime/src/domains/meta-picklist-binding-door.test.ts new file mode 100644 index 00000000000..6961c578924 --- /dev/null +++ b/packages/runtime/src/domains/meta-picklist-binding-door.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29898] The metadata save and publish doors refuse a select field bound to + * a picklist no loaded package declares, and judge it against the REAL + * `SchemaRegistry` — the read that serves the field's options. + * + * Measured on `origin/main` before this file: a runtime package's object with + * `picklist: 'nope_list'` saved as a draft and published; the field was then + * served with no options, and the record validator refused every value. + * + * The protocol-level pins (every door, the envelope, a draft stored before the + * rule, the held-binding and healing-pass controls) live in + * `packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts` + * over a registry double. What only a real registry answers is pinned here: + * the refusal reads the registry's own `resolvePicklistOptions`, and a list + * declared by any of these is NOT refused — + * - a dependency of the writing package (the base, declared in the runtime + * package's `dependencies`); + * - the base of a `.custom` pair, for an extension saved into the pair; + * - another package installed here that the writing package does not depend + * on. + * + * ## The rig + * + * The rig of `meta-object-extension-item.test.ts`, kept local: better-sqlite3 + * `:memory:` behind the real `ObjectQL` engine, the real `sys_metadata*` + * objects and a real `ObjectStackProtocolImplementation` over its + * `SchemaRegistry`. The base and the list package are code packages registered + * through `registerApp`; the runtime package and the pair are installed through + * the protocol's package door. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; + +let home: string; +let priorHome: string | undefined; +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-picklist-binding-door-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +const TEST_PACKAGE_ID = 'objectstack-test'; +/** A code package: declares `crm_tier` and owns the account. */ +const BASE = 'com.acme.crm'; +/** The base's writable `.custom` pair. */ +const PAIR = 'com.acme.crm.custom'; +/** A code package installed here that declares `shared_region`; nothing depends on it. */ +const LISTS = 'com.acme.lists'; +/** A writable runtime package that depends on the base only. */ +const APP = 'com.acme.deals'; +const TARGET = 'crm_account'; +const DEAL = 'deals_probe'; + +const baseManifest = () => ({ + id: BASE, + name: 'crm', + version: '3.4.1', + type: 'app', + namespace: 'crm', + picklists: [{ name: 'crm_tier', label: 'Tier', options: [{ label: 'Gold', value: 'gold' }] }], + objects: [{ + name: TARGET, + label: 'Account', + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, + }], +}); + +const listsManifest = () => ({ + id: LISTS, + name: 'lists', + version: '1.0.0', + type: 'module', + namespace: 'lists', + picklists: [{ name: 'shared_region', label: 'Region', options: [{ label: 'EMEA', value: 'emea' }] }], +}); + +/** A runtime package's object with one select field bound to `picklist`. */ +const deal = (picklist: string) => ({ + name: DEAL, + label: 'Deal', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + tier: { type: 'select', label: 'Tier', picklist }, + }, +}); + +/** Studio's add-field body in the pair: a select on the base's account bound to `picklist`. */ +const tierExtension = (picklist: string) => ({ + extend: TARGET, + fields: { tier__c: { type: 'select', label: 'Tier', picklist } }, +}); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; + engine.registerApp(baseManifest()); + engine.registerApp(listsManifest()); + await engine.syncSchemas(); + + const p = new ObjectStackProtocolImplementation(engine as any, () => new Map()) as any; + await p.installPackage({ + manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies: { [BASE]: '^3' } }, + }); + await p.installPackage({ + manifest: { id: APP, name: 'deals', version: '0.1.0', type: 'app', namespace: 'deals', dependencies: { [BASE]: '^3' } }, + }); + + /** A draft save naming the package, then the per-item publish naming it. */ + const saveAndPublish = async (type: string, name: string, packageId: string, item: unknown) => { + await p.saveMetaItem({ type, name, item, mode: 'draft', packageId }); + return p.publishMetaItem({ type, name, packageId }); + }; + /** Every stored row of a type, read back from the store. */ + const rows = async (type: string) => ((await engine.find('sys_metadata', { where: { type }, context: { isSystem: true } })) ?? []) + .map((r: any) => `${r.name}|${r.package_id ?? '-'}|${r.state}`); + /** The options a field is served with, through the registry's fold. */ + const servedOptions = (object: string, field: string) => + ((engine.registry.getObject(object) as any)?.fields?.[field]?.options ?? []).map((o: any) => o?.value); + return { engine, protocol: p, saveAndPublish, rows, servedOptions }; +} + +const refusalOf = (run: Promise) => run.then(() => null, (e: any) => e); + +const expectRefusal = (err: any, object: string, field: string, picklist: string) => { + expect({ code: err?.code, status: err?.status }, String(err?.stack ?? err)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => i.path)).toEqual([`fields.${field}.picklist`]); + expect(String(err.issues[0].message).startsWith( + `Field '${object}.${field}' is bound to picklist '${picklist}', which no loaded package declares`, + ), err.issues[0].message).toBe(true); +}; + +describe('[#29898] the save door, over the real registry — a list no loaded package declares', () => { + it('a runtime package\'s object draft is refused 422 naming the field and the list; nothing is stored', async () => { + const rig = await boot(); + + const err = await refusalOf(rig.protocol.saveMetaItem({ + type: 'object', name: DEAL, item: deal('nope_list'), mode: 'draft', packageId: APP, + })); + + expectRefusal(err, DEAL, 'tier', 'nope_list'); + expect(await rig.rows('object')).toEqual([]); + }, 60_000); + + it('an extension saved into the `.custom` pair is refused the same way', async () => { + const rig = await boot(); + + const err = await refusalOf(rig.protocol.saveMetaItem({ + type: 'object_extension', name: TARGET, item: tierExtension('nope_list'), mode: 'draft', packageId: PAIR, + })); + + expectRefusal(err, TARGET, 'tier__c', 'nope_list'); + expect(await rig.rows('object_extension')).toEqual([]); + }, 60_000); +}); + +describe('[#29898] controls over the real registry — a declared list is never refused', () => { + it('a list a dependency declares: the runtime package\'s object saves, publishes and is served the list', async () => { + const rig = await boot(); + + const published = await rig.saveAndPublish('object', DEAL, APP, deal('crm_tier')); + + expect(published?.success, JSON.stringify(published)).toBe(true); + expect(await rig.rows('object')).toEqual([`${DEAL}|${APP}|active`]); + expect(rig.servedOptions(DEAL, 'tier')).toEqual(['gold']); + }, 60_000); + + it('a list the base of a `.custom` pair declares: the pair\'s extension saves, publishes and is served the list', async () => { + const rig = await boot(); + + const published = await rig.saveAndPublish('object_extension', TARGET, PAIR, tierExtension('crm_tier')); + + expect(published?.success, JSON.stringify(published)).toBe(true); + expect(await rig.rows('object_extension')).toEqual([`${TARGET}|${PAIR}|active`]); + expect(rig.servedOptions(TARGET, 'tier__c')).toEqual(['gold']); + }, 60_000); + + it('a list another installed package declares, which the writing package does not depend on', async () => { + const rig = await boot(); + + const published = await rig.saveAndPublish('object', DEAL, APP, deal('shared_region')); + + expect(published?.success, JSON.stringify(published)).toBe(true); + expect(await rig.rows('object')).toEqual([`${DEAL}|${APP}|active`]); + expect(rig.servedOptions(DEAL, 'tier')).toEqual(['emea']); + }, 60_000); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.picklist-binding-unknown-refused.ts b/packages/spec/src/migrations/entries/semantic/18.picklist-binding-unknown-refused.ts new file mode 100644 index 00000000000..f8cebab263e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.picklist-binding-unknown-refused.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'picklist-binding-unknown-refused', + surface: + 'data.Field.picklist on an object or an object extension saved or published through the metadata doors ' + + '(PUT /api/v1/meta/object/NAME, PUT /api/v1/meta/object_extension/NAME, POST /api/v1/packages/PKG/publish-drafts, ' + + 'the per-item publish and the restore doors): a select or multiselect field bound to a picklist name that no ' + + 'loaded package declares', + replacement: + 'Bind the field to a picklist a package installed here declares (the package\'s own, a dependency\'s, the base ' + + 'package\'s of a .custom pair, or another installed package\'s), declare the picklist in a code package ' + + '(a *.picklist.ts file, or defineStack({ picklists })), or give the field its own options instead of picklist', + reason: + 'A field bound to a picklist takes its options from that list and has none of its own. The metadata save and ' + + 'publish doors stored and published a field naming a list no loaded package declares (its draft read ' + + '_diagnostics.valid: true); the field was then served with no options, and the record validator refused every ' + + 'value of it ("takes its values from picklist ..., 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. The doors now refuse it 422 INVALID_METADATA at fields.FIELD.picklist, naming the field ' + + 'and the list. A binding the object already holds is not judged again on a re-save. There is no mechanical ' + + 'rewrite: whether the author meant another list, a list still to be declared or inline options is a judgement ' + + 'about the field.', + acceptanceCriteria: + 'PUT /api/v1/meta/object/NAME?mode=draft&package=PKG with every picklist-bound field naming a declared list ' + + 'answers 200; POST /api/v1/packages/PKG/publish-drafts publishes it; GET /api/v1/meta/object/NAME serves each ' + + 'bound field with its options; POST /api/v1/data/NAME with one of those values answers 200.', +}; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index e3bbce8e4ce..b1ef6b8b876 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2086,6 +2086,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts", "verb": "delete",