Found while implementing #11637 (making RestServer.normalizeConfig parse config.api instead of casting to it). Filed, not fixed — the repair is either a packages/spec enum change (domain:spec's single-owner surface) or a project-scoping semantics change in @objectstack/runtime, and #11637 owns neither.
Three packages disagree about this key's vocabulary, and they have disagreed silently for exactly as long as nothing executed the schema. CI on PR #11985 is what made it visible: five packages/cli e2e boots died at Plugin startup failed: com.objectstack.rest.api the moment the parse started running.
What was measured
On origin/main @ 7899f5745.
The declaration — packages/spec/src/api/rest-server.zod.ts:113:
projectResolution: z.enum(['required','optional','auto']).default('auto').describe('Project ID resolution strategy'),The producer — packages/runtime/src/standalone-stack.ts. Not a stray literal: the value is in the declared return type, :247:
exportinterfaceStandaloneStackResult{plugins: any[];api: {enableProjectScoping: false;projectResolution: 'none'};and emitted at :760-763:
return{plugins,api: {enableProjectScoping: false,projectResolution: 'none',},The consumers — packages/cli/src/commands/serve.ts:
:3086constapiConfig=(configasany).api??{};
:3088constprojectResolution=apiConfig.projectResolution??'auto';// 'none' is not nullish — it survives
:3139createRestApiPlugin({api: {api: { enableProjectScoping, projectResolution }}asany}),// → the REST plugin
:3160scoping: { enableProjectScoping, projectResolution },// → the Dispatcher pluginSo 'none' reaches two plugins. packages/cli/src/utils/merge-boot-config.ts:12 documents the same block as the boot default, and merge-boot-config.test.ts:7 pins it as const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const.
Why nobody noticed.RestServer cast this config rather than parsing it, so the enum never executed on any deployment path (#11637's whole subject). 'none' was accepted because nothing checked. Downstream, rest-server.ts only ever compares projectResolution === 'required', and direct-mount-composition.ts:91 does composition.projectResolution ?? 'auto' — so an unrecognised value silently behaves like 'auto' without ever being named as such.
Every other projectResolution value in the repo is legal: a repo-wide census of literals (173 files, 316 api: { … } blocks) found 'auto', 'optional', 'required' and 'none', and 'none' only on this boot path.
Why it is a defect either way
'none' and 'auto' are not synonyms in intent. 'none' reads as "no environment scoping at all"; 'auto' is declared as "backward compatible — accepts both scoped and unscoped routes". A standalone host currently declares the first and gets the second's behaviour by fallthrough. So this is not merely a spelling mismatch to paper over — either the enum is missing a member the platform genuinely uses, or the runtime is shipping a value it does not mean.
Not prejudged
- Teach the enum
'none' (z.enum(['required', 'optional', 'auto', 'none'])) and give it an explicit branch wherever projectResolution is read, so "no scoping" stops being expressed as an unrecognised string that falls through to 'auto'. This is the shape that keeps the runtime's stated intent. - Migrate the runtime to a declared value. If
'none' and 'auto' really are the same behaviour for a standalone host with enableProjectScoping: false, change StandaloneStackResult to 'auto' and delete the divergence. Note enableProjectScoping: false already makes the resolution strategy moot on that path, which is evidence for this option — worth confirming before choosing. - Either way the fix wants a pin that the CLI's real boot config parses against the declared schema, which is the check that did not exist and is why this survived.
⚠️ Until this is settled, #11985.omit()s projectResolution from the parse it runs, exactly as it does for the retired api.requireAuth — so the key is still unvalidated at that seam. Closing this issue is what lets the omit be removed.
Generated by Claude Code
Found while implementing #11637 (making
RestServer.normalizeConfigparseconfig.apiinstead of casting to it). Filed, not fixed — the repair is either apackages/specenum change (domain:spec's single-owner surface) or a project-scoping semantics change in@objectstack/runtime, and #11637 owns neither.Three packages disagree about this key's vocabulary, and they have disagreed silently for exactly as long as nothing executed the schema. CI on PR #11985 is what made it visible: five
packages/clie2e boots died atPlugin startup failed: com.objectstack.rest.apithe moment the parse started running.What was measured
On
origin/main@7899f5745.The declaration —
packages/spec/src/api/rest-server.zod.ts:113:The producer —
packages/runtime/src/standalone-stack.ts. Not a stray literal: the value is in the declared return type,:247:and emitted at
:760-763:The consumers —
packages/cli/src/commands/serve.ts:So
'none'reaches two plugins.packages/cli/src/utils/merge-boot-config.ts:12documents the same block as the boot default, andmerge-boot-config.test.ts:7pins it asconst BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const.Why nobody noticed.
RestServercast this config rather than parsing it, so the enum never executed on any deployment path (#11637's whole subject).'none'was accepted because nothing checked. Downstream,rest-server.tsonly ever comparesprojectResolution === 'required', anddirect-mount-composition.ts:91doescomposition.projectResolution ?? 'auto'— so an unrecognised value silently behaves like'auto'without ever being named as such.Every other
projectResolutionvalue in the repo is legal: a repo-wide census of literals (173 files, 316api: { … }blocks) found'auto','optional','required'and'none', and'none'only on this boot path.Why it is a defect either way
'none'and'auto'are not synonyms in intent.'none'reads as "no environment scoping at all";'auto'is declared as "backward compatible — accepts both scoped and unscoped routes". A standalone host currently declares the first and gets the second's behaviour by fallthrough. So this is not merely a spelling mismatch to paper over — either the enum is missing a member the platform genuinely uses, or the runtime is shipping a value it does not mean.Not prejudged
'none'(z.enum(['required', 'optional', 'auto', 'none'])) and give it an explicit branch whereverprojectResolutionis read, so "no scoping" stops being expressed as an unrecognised string that falls through to'auto'. This is the shape that keeps the runtime's stated intent.'none'and'auto'really are the same behaviour for a standalone host withenableProjectScoping: false, changeStandaloneStackResultto'auto'and delete the divergence. NoteenableProjectScoping: falsealready makes the resolution strategy moot on that path, which is evidence for this option — worth confirming before choosing.#11985.omit()sprojectResolutionfrom the parse it runs, exactly as it does for the retiredapi.requireAuth— so the key is still unvalidated at that seam. Closing this issue is what lets the omit be removed.Generated by Claude Code