Skip to content

Commit 35ffb77

Browse files
fix(objectql)!: refuse a composed stack's top-level picklist that no package body declares and no object field names (#28231)
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL ## 维护者速读 - 问题:带 `packages[]` 的组合栈,若顶层声明了一个 picklist,既没有任何包体声明、也没有任何对象字段引用,启动照常成功,该列表在引擎注册表里静默缺失。 - 修复:`manifest` 服务在注册任何内容之前拒绝这种工件,返回 `INVALID_METADATA`(422),消息点名每个 picklist 和所属栈,并给出处方。 - 处方:在使用它的包的 `packages[]` 条目里声明;或由对象字段引用它;或从顶层删除。 - 不变:被对象字段引用的残余 picklist 保持现有行为(顶层对象"列出但不服务"、包体字段由 picklist 审计拒绝);包体声明的 picklist、无残余 picklist 的栈、不带 `packages[]` 的栈均不变。 - 破坏性:是,收窄(以前能启动的组合现在被拒绝),patch 级 changeset,无 schema 变更,ADR-0087 not-required。 - 文件面:仅 `packages/objectql/src/plugin.ts` 的残余注册段、其残余测试文件和一个 changeset。 Clause-②: yes (narrowing) — a stack with `packages[]` whose top level declares a picklist that no package body declares and no object field names booted, and now refuses the boot with `INVALID_METADATA` (422) Metadata-protocol: none ## What changed The `manifest` service registers a composed stack's residual (top-level items no package body declares) under the stack's own id, except picklists, which stay metadata only (`RESIDUAL_TYPES_WITH_OWN_PATH`). A residual picklist that an object field names was loud already. One that no object field names was neither registered nor refused. `unreferencedResidualPicklistRefusal` (in `packages/objectql/src/plugin.ts`) reads the residual rule's own answer (`unclaimedTopLevel`), takes its picklists, and keeps those that no object field names. "Names" is judged off the artifact alone: its top level and every package body, objects and `objectExtensions` both, through the existing `collectManifestPicklistReferences`. It is never judged off the registry, which is still filling during a boot. If any are left, `register` throws before anything of the artifact registers. The refusal uses the existing picklist envelope `picklistMetadataError` (`INVALID_METADATA`, 422), so no new error code is added. The residual objects rule's line does not move. ## Measurement (on `main` at `35f20551`, then on this branch) | Composition | `main` | This branch | |:---|:---|:---| | Residual picklist that no object field names | boots, `state=running`; `resolvePicklistOptions('acme_unused')` is `undefined`; no warning names it | refused at boot, `INVALID_METADATA`, the message names `acme_unused` and stack `com.example.acme.release` | | `packages: []` with only such a picklist on the top level | boots; the list is absent; nothing names it | refused, as above | | CONTROL: residual picklist that a residual object's field names | boots; the object is listed and not served (one warning); the list is not registered | unchanged | | CONTROL: picklist a body declares | boots; the list resolves to `['open']` | unchanged | ## Pins (`manifest-service-residual-objects.test.ts`, new `describe`) On `main`'s `plugin.ts`: 4 red, 5 green (the 5 controls). On this branch: 9 of 9 green. Both residual test files: 72 of 72 green. Full `@objectstack/objectql` suite: green (counts under Gates). - Red on `main`, green here: the boot refusal (code, status, picklist, stack, all three remedies); several picklists named in one refusal, and not one a field names; `packages: []` with only such a picklist; after `start()`, the artifact is refused before anything of it registers. - Controls (green on both): a residual picklist a residual object's field names keeps the listed-and-not-served line, and the list stays unregistered; a residual picklist a body object's field names still fails the boot's picklist audit, not this refusal; a body-declared picklist registers whether or not the top level repeats it; a stack with no residual picklist boots unchanged; a stack without `packages[]` registers its unnamed picklist as before. Ablations, each restored from the committed head: | Ablation | Red | |:---|:---| | A1: drop the throw | the 4 refusal pins | | A2: treat no picklist as named | both "a field names it" controls, the "several in one refusal" pin, and the existing listed-and-not-served pin | | A3: count references on the top level only, not in bodies | the body-field audit control | | A4: take every top-level picklist, not only the residual's | the body-declared control and the no-`packages[]` control | | A5: refuse whenever there is a residual | 52 tests, the no-residual-picklist control among them | ## Gates Run on the head `b4c76532`, which merges `main` at `7811c3f6`, after a full rebuild: - `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 61 families. All 61 were run and all exited 0. `--ran` reports 61 run, 0 NOT-MEASURED and 0 UNRUN, and exits 0. - The derivation names some work outside those 61 that cannot run locally: 4 families that take a value from the workflow, 5 CI jobs scheduled by these paths, and 5 type-check lanes. These are NOT MEASURED here, and CI runs them. - `pnpm lint`: exit 0. `pnpm --filter @objectstack/objectql run typecheck`: exit 0. - Test suites: `@objectstack/objectql` 9732 of 9732 green, `@objectstack/runtime` 8855 green with 19 skipped, `@objectstack/metadata` 906 of 906 green. Fixes #28159 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03069ca commit 35ffb77

3 files changed

Lines changed: 223 additions & 1 deletion

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a composed stack's top-level picklist that no package body declares and no object field names is refused at boot, naming the list and the stack (#28159)
6+
7+
Clause-②: yes (narrowing) — a stack with `packages[]` whose top level declares a picklist that no package body declares and no object field names booted, and now refuses the boot with `INVALID_METADATA` (422)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and the picklist schema accepts what it accepted. The boot refuses one composition it used to accept silently, and the refusal names its own remedy; there is no metadata for a migration to rewrite. -->
11+
12+
**BREAKING** for a composed stack only (a stack that carries `packages[]`). A stack without `packages[]` has no residual and is unchanged.
13+
14+
The engine registers a stack's top-level items that no package body declares (its residual) under the stack's own id, except picklists, which stay metadata only. A residual picklist that an object field names was loud already: a top-level object's field makes that object listed and not served, and a package body's field fails the boot's picklist audit. A residual picklist that no object field names was neither registered nor refused, so the boot booted and the list was missing from the engine with nothing said.
15+
16+
- **Before.** The boot succeeded and the picklist was absent from the engine registry.
17+
- **Now.** The `manifest` service refuses the artifact before anything of it registers, with `INVALID_METADATA` (422). The message names each such picklist and the stack, and gives the remedy. A field counts as naming the list only when the engine registers it: a field of a package body's object or `objectExtensions` entry, or of a top-level object no body declares. A top-level `objectExtensions` entry, a top-level copy of an object a body declares, and a top-level `plugins[]` entry are not registered in a composed stack, so a list that only they name is refused too, as is one that only a body's `picklistExtensions` entry extends.
18+
- **Unchanged.** A residual picklist that an object field names keeps today's behaviour, a picklist a package body declares registers as before, and a stack with no residual picklist boots as before.
19+
20+
**The remedy.** Declare the picklist inside the `packages[]` entry of the package whose objects use it; or name it from a field of the object meant to use it; or remove it from the top level.

‎packages/objectql/src/manifest-service-residual-objects.test.ts‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,3 +542,142 @@ describe('ADR-0130 D4 — the manifest service registers every other residual ty
542542
}
543543
});
544544
});
545+
546+
/**
547+
* [ADR-0130 D4] A residual PICKLIST that no object field of the stack names is
548+
* refused at boot. The residual's picklists stay metadata only (the objects
549+
* rule above is judged on that premise), so one that no field names was
550+
* neither registered nor refused: the boot booted, and the list was missing
551+
* from the engine with nothing said. One that a field names is loud already,
552+
* and stays as it is: a residual object's field makes that object listed and
553+
* not served (above), and a body's field fails the boot's picklist audit.
554+
*/
555+
describe('ADR-0130 D4 — a residual picklist that no object field names', () => {
556+
const unusedList = (name = 'acme_unused') => ({ name, label: 'Unused', options: [{ label: 'A', value: 'a' }] });
557+
const refusalOf = (booting: Promise<unknown>): Promise<unknown> => booting.then(() => undefined, (e: unknown) => e);
558+
559+
it('refuses the boot, naming the picklist and the stack, with the prescription', async () => {
560+
const refusal = await refusalOf(boot(registering('app', [residualStack(noteObj(), { picklists: [unusedList()] })])));
561+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA', status: 422, httpStatus: 422 });
562+
const message = (refusal as Error).message;
563+
expect(message).toContain(`Picklist 'acme_unused' is declared on the top level of stack '${RELEASE_ID}'`);
564+
expect(message).toContain('no package body declares it and no object field the engine registers names it');
565+
expect(message).toContain('Declare it inside the `packages[]` entry of the package whose objects use it');
566+
expect(message).toContain('name it from a field of the object meant to use it');
567+
expect(message).toContain('remove it from the top level');
568+
});
569+
570+
it('names every such picklist in one refusal, and not one a field names', async () => {
571+
const refusal = await refusalOf(boot(registering('app', [
572+
residualStack(noteObj(statusField), { picklists: [statusList(), unusedList(), unusedList('acme_spare')] }),
573+
])));
574+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA' });
575+
const message = (refusal as Error).message;
576+
expect(message).toContain(`Picklists 'acme_unused', 'acme_spare' are declared on the top level of stack '${RELEASE_ID}'`);
577+
expect(message).not.toContain("'acme_status'");
578+
});
579+
580+
it('refuses a stack whose `packages: []` leaves only such a picklist', async () => {
581+
const refusal = await refusalOf(boot(registering('app', [payload({
582+
manifest: { ...app, id: RELEASE_ID },
583+
picklists: [unusedList()],
584+
packages: [],
585+
})])));
586+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA' });
587+
expect((refusal as Error).message).toContain(`Picklist 'acme_unused' is declared on the top level of stack '${RELEASE_ID}'`);
588+
});
589+
590+
it('after start(), refuses the artifact before anything of it registers', async () => {
591+
const { kernel } = await boot();
592+
const manifest = kernel.getService<ManifestService>('manifest');
593+
const refusal = await refusalOf(Promise.resolve().then(() =>
594+
manifest.register(residualStack(noteObj(), { picklists: [unusedList()] }))));
595+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA' });
596+
expect(owners(engineOf(kernel))).toEqual({ acme_account: undefined, acme_case: undefined, acme_note: undefined });
597+
expect(acmePackageIds(engineOf(kernel))).toEqual([]);
598+
});
599+
600+
/** A residual `acme_status` that only an item the engine never registers in a composed stack names. */
601+
const isTopLevelOnlyRefusal = (refusal: unknown): void => {
602+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
603+
expect((refusal as Error).message).toContain(`Picklist 'acme_status' is declared on the top level of stack '${RELEASE_ID}'`);
604+
};
605+
606+
it('refuses one that only a top-level `objectExtensions` entry names — no door registers that entry in a composed stack', async () => {
607+
isTopLevelOnlyRefusal(await refusalOf(boot(registering('app', [residualStack(noteObj(), {
608+
picklists: [statusList()],
609+
objectExtensions: [{ extend: 'acme_case', fields: statusField }],
610+
})]))));
611+
});
612+
613+
it('refuses one that only a top-level copy of a body-declared object names, its fields diverging from the body\'s', async () => {
614+
isTopLevelOnlyRefusal(await refusalOf(boot(registering('app', [payload({
615+
manifest: { ...app, id: RELEASE_ID },
616+
objects: [{ ...caseObj(), fields: { ...caseObj().fields, ...statusField } }, accountObj()],
617+
picklists: [statusList()],
618+
packages: [
619+
{ manifest: { ...svc, objects: [caseObj()] } },
620+
{ manifest: { ...app, objects: [accountObj()] } },
621+
],
622+
})]))));
623+
});
624+
625+
it('refuses one that only a body\'s `picklistExtensions` entry extends — at boot, and after start() before anything registers', async () => {
626+
const extending = () => residualStack(noteObj(), { picklists: [statusList()] }, {
627+
picklistExtensions: [{ extend: 'acme_status', options: [{ label: 'Closed', value: 'closed' }] }],
628+
});
629+
isTopLevelOnlyRefusal(await refusalOf(boot(registering('app', [extending()]))));
630+
631+
const { kernel } = await boot();
632+
isTopLevelOnlyRefusal(await refusalOf(Promise.resolve().then(() =>
633+
kernel.getService<ManifestService>('manifest').register(extending()))));
634+
expect(owners(engineOf(kernel))).toEqual({ acme_account: undefined, acme_case: undefined, acme_note: undefined });
635+
expect(acmePackageIds(engineOf(kernel))).toEqual([]);
636+
});
637+
638+
it('CONTROL: one a residual object\'s field names keeps the objects rule\'s line — boots, listed and not served, list unregistered', async () => {
639+
const { kernel, recorder } = await boot(registering('app', [
640+
residualStack(noteObj(statusField), { picklists: [statusList()] }),
641+
]));
642+
const ql = engineOf(kernel);
643+
expect(kernel.getState()).toBe('running');
644+
expect(owners(ql).acme_note).toBeUndefined();
645+
expect(ql.registry.resolvePicklistOptions('acme_status')).toBeUndefined();
646+
expect(unservedLines(recorder)).toHaveLength(1);
647+
});
648+
649+
it('CONTROL: one a BODY object\'s field names still fails the boot\'s picklist audit, not this refusal', async () => {
650+
const refusal = await refusalOf(boot(registering('app', [
651+
residualStack(noteObj(), { picklists: [statusList()] }, { objects: [{ ...caseObj(), fields: { ...caseObj().fields, ...statusField } }] }),
652+
])));
653+
expect(refusal).toMatchObject({ code: 'INVALID_METADATA' });
654+
const message = (refusal as Error).message;
655+
expect(message).toContain("field 'acme_case.status'");
656+
expect(message).toContain('that no loaded package declares');
657+
expect(message).not.toContain('is declared on the top level of stack');
658+
});
659+
660+
it('CONTROL: a picklist a body declares registers as today, whether or not the top level repeats it', async () => {
661+
for (const extra of [{ picklists: [statusList()] }, {}]) {
662+
const { kernel, recorder } = await boot(registering('app', [residualStack(noteObj(), extra, { picklists: [statusList()] })]));
663+
const ql = engineOf(kernel);
664+
expect(kernel.getState()).toBe('running');
665+
expect(ql.registry.resolvePicklistOptions('acme_status')?.map((o) => o.value)).toEqual(['open']);
666+
expect(unservedLines(recorder)).toEqual([]);
667+
await kernel.shutdown();
668+
}
669+
});
670+
671+
it('CONTROL: a stack with no residual picklist boots unchanged', async () => {
672+
const { kernel, recorder } = await boot(registering('app', [residualStack()]));
673+
expect(kernel.getState()).toBe('running');
674+
expect(owners(engineOf(kernel))).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: RELEASE_ID });
675+
expect(unservedLines(recorder)).toEqual([]);
676+
});
677+
678+
it('CONTROL: a stack without `packages[]` has no residual — its unnamed picklist registers as today', async () => {
679+
const { kernel } = await boot(registering('app', [payload({ manifest: app, objects: [noteObj()], picklists: [unusedList()] })]));
680+
expect(kernel.getState()).toBe('running');
681+
expect(engineOf(kernel).registry.resolvePicklistOptions('acme_unused')?.map((o) => o.value)).toEqual(['a']);
682+
});
683+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import {
4848
collectManifestPicklistNames,
4949
collectManifestPicklistReferences,
5050
describeUnresolvedPicklistReferences,
51+
picklistMetadataError,
52+
type PicklistMetadataError,
5153
} from './picklist-resolution.js';
5254
// [ADR-0048 N.3] The security catalog's one-holder envelope, raised here by the
5355
// cold-boot check (see `refuseEnvironmentHeldSecurityCatalogNames`).
@@ -170,7 +172,9 @@ function unclaimedTopLevelObjects(
170172
* residual declares is what the objects rule lists and does not serve, and
171173
* a body's field naming one fails the boot's picklist audit — both loud,
172174
* and both judged on that premise, so registering the residual's lists here
173-
* would change which objects the data door serves;
175+
* would change which objects the data door serves. A residual list no
176+
* object field names is refused instead
177+
* ({@link unreferencedResidualPicklistRefusal});
174178
* - `object_extension`: an extension has no slot of its own (it is keyed by
175179
* `extend`, and its `name` spelling is a retired alias the schema refuses),
176180
* so the residual rule never lists one.
@@ -208,6 +212,60 @@ function unclaimedTopLevelItems(
208212
: undefined;
209213
}
210214

215+
/**
216+
* [ADR-0130 D4] The refusal for a residual PICKLIST that no object field of
217+
* the stack names — `undefined` when there is none.
218+
*
219+
* A residual picklist is kept as metadata only (see
220+
* {@link RESIDUAL_TYPES_WITH_OWN_PATH}), so the engine never registers it.
221+
* One that a field names is loud already: a residual object's field makes
222+
* that object listed and not served, and a body's field fails the boot's
223+
* picklist audit. One that no field names was neither registered nor
224+
* refused, so it went missing from the engine with nothing said. It is
225+
* refused here, before anything of the artifact registers, naming each list
226+
* and the stack.
227+
*
228+
* "Names" is judged off the fields the engine registers from this artifact:
229+
* every package body's (its objects, its `objectExtensions` and its nested
230+
* `plugins[]`) and the residual's own objects, the set
231+
* {@link ObjectQLPlugin.registerUnclaimedTopLevelObjects} judges. ⛔ Never off
232+
* the whole top level: in a composed stack a top-level `objectExtensions`
233+
* entry, a top-level copy of an object a body declares and a top-level
234+
* `plugins[]` entry are registered by no door, so a list only they name is
235+
* dropped all the same. And never off the registry, which is still filling
236+
* while a boot registers.
237+
*/
238+
function unreferencedResidualPicklistRefusal(
239+
residual: UnclaimedTopLevel | undefined,
240+
bodies: readonly unknown[],
241+
): PicklistMetadataError | undefined {
242+
if (!residual) return undefined;
243+
const picklists = residual.items
244+
.filter((entry) => entry.type === 'picklist')
245+
.map((entry) => String((entry.item as { name?: unknown }).name));
246+
if (picklists.length === 0) return undefined;
247+
const named = new Set<string>();
248+
const residualObjects = residual.items.filter((entry) => entry.type === 'object').map((entry) => entry.item);
249+
for (const source of [...bodies, { objects: residualObjects }]) {
250+
for (const ref of collectManifestPicklistReferences(source, undefined)) named.add(ref.picklist);
251+
}
252+
const unreferenced = picklists.filter((name) => !named.has(name));
253+
if (unreferenced.length === 0) return undefined;
254+
const owner = residual.ownerId ?? '<none>';
255+
const listed = unreferenced.map((name) => `'${name}'`).join(', ');
256+
return picklistMetadataError(
257+
`${unreferenced.length === 1 ? 'Picklist' : 'Picklists'} ${listed} ${unreferenced.length === 1 ? 'is' : 'are'} `
258+
+ `declared on the top level of stack '${owner}', and no package body declares `
259+
+ `${unreferenced.length === 1 ? 'it' : 'them'} and no object field the engine registers names `
260+
+ `${unreferenced.length === 1 ? 'it' : 'them'} (in a composed stack, a top-level \`objectExtensions\` entry, a `
261+
+ 'top-level copy of an object a package body declares and a top-level `plugins[]` entry are not '
262+
+ 'registered). The engine registers a top-level picklist only through a package body, so this list would be missing from the engine with nothing said. Declare it inside the '
263+
+ '`packages[]` entry of the package whose objects use it; or name it from a field of the object meant to '
264+
+ 'use it (a top-level object that does is then listed and not served until both move into a package '
265+
+ 'body); or remove it from the top level.',
266+
);
267+
}
268+
211269
/**
212270
* Options for ObjectQLPlugin.
213271
*
@@ -831,6 +889,11 @@ export class ObjectQLPlugin implements Plugin {
831889
// which readers of the registry alone see only through this. See
832890
// {@link unclaimedTopLevelItems}.
833891
const residualItems = unclaimedTopLevelItems(residualAnswer);
892+
// …and its picklists, which neither of those registers: one that no
893+
// object field names is refused here, before anything registers. See
894+
// {@link unreferencedResidualPicklistRefusal}.
895+
const unreferencedPicklists = unreferencedResidualPicklistRefusal(residualAnswer, ordered);
896+
if (unreferencedPicklists) throw unreferencedPicklists;
834897

835898
if (ordered.length === 0 && residual === undefined && residualItems === undefined) {
836899
// An artifact that declared `packages: []` registers nothing. Said out

0 commit comments

Comments
 (0)