Skip to content

projectResolution: 'none' is shipped by @objectstack/runtime and forwarded by os serve, but RestApiConfigSchema declares only required|optional|auto — accepted only because the schema was never executed #11999

Description

@os-zhuang

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 declarationpackages/spec/src/api/rest-server.zod.ts:113:

projectResolution: z.enum(['required','optional','auto']).default('auto').describe('Project ID resolution strategy'),

The producerpackages/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 consumerspackages/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 plugin

So '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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions