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 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:

export interface StandaloneStackResult {
    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:

:3086   const apiConfig = (config as any).api ?? {};
:3088   const projectResolution = apiConfig.projectResolution ?? 'auto';   // 'none' is not nullish — it survives
:3139   createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution } } as any }),   // → the REST plugin
:3160   scoping: { 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

Activity

  1. self-assigned this
    on Aug 26, 2026
  2. os-litant commented on Aug 26, 2026

    @os-litant
    Collaborator

    Claim: PM loop round R39
    Session: session_01UjujZN219uFzBhSYfMykCd
    Branch: claude/issue-11999-project-resolution-none
    Worktree: directory objectstack-issue-11999
    Domain: domain:cli
    File surface: packages/runtime/src/standalone-stack.ts, packages/cli/src/utils/merge-boot-config.ts, and their sibling tests (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus. Tier line from node scripts/pm/dispatch-gates.mjs --tier over that surface: "no path-derived mandate", so the tier is this seat's judgment; set at the default judgment tier because the card carries a genuine fork, ⛔ not a mechanical edit.
    Clause-②: no
    Serial constraints cleared: R39 siblings are #11624 (packages/cli/src/utils/i18n-extract.ts, packages/lint/**) and #11473 (packages/rest + packages/qa test fixtures) — both disjoint. packages/client/src/index.ts and packages/rest/src/rest-server.ts are held by PR #12421 (#11926), ACCEPTED but ⛔ NOT merged — ruling ①, the merge or a close releases a serial, not the arming. This surface touches neither. packages/cli/src/commands/serve.ts is a consumer of this key and is NOT in this surface — it is also the surface of queued card #12151, so touching it would create a collision. Surface does not intersect the #7898 H17 index.

    ⚠️ Ruling with a premise — read this before choosing a route

    The card names two routes and deliberately does not prejudge. This seat rules route 2 (migrate the runtime to a declared value), and hangs that ruling on a named, falsifiable premise rather than asserting it:

    Premise: enableProjectScoping: false already makes the resolution strategy moot on the standalone path, so 'none' and 'auto' are the same behaviour there.

    The card itself offers this as evidence for route 2 and says it is "worth confirming before choosing." ⛔ Confirm it before you write the fix.

    • Premise holds ⇒ change StandaloneStackResult to a declared value, delete the divergence, and add the pin the card asks for: that the CLI's real boot config parses against the declared schema. That pin is the check that never existed and is why this survived.
    • ⛔ Premise fails — 'none' genuinely means something 'auto' does not on that path ⇒ STOP and report the fork. ⛔ Do not quietly switch to route 1, and ⛔ do not implement both.

    Why route 1 is barred without a report: teaching the enum 'none' edits packages/spec/src/api/rest-server.zod.ts. That is (a) domain:spec's single-owner surface, so the card would leave this lane, and (b) a widening of a declared accept set, which is Clause ② and would need the contract-review tier. ⛔ Neither is this seat's to take unilaterally, and the Clause-②: no above is scored for route 2 only. Taking route 1 voids it.

    ⛔ packages/spec/** is READ-ONLY on this card under either route.

    ⚠️ Note the downstream fallthrough the card measured: direct-mount-composition.ts does ?? 'auto' and rest-server.ts only ever compares against 'required', so an unrecognised value behaves as 'auto' without ever being named as such. That silence is the thing being fixed — ⛔ a repair that leaves the value unrecognised-but-working is not a repair.

    Landing note, ⛔ not scope: #11985 currently .omit()s projectResolution from its parse. Closing this card is what lets that omit be removed. ⛔ Do not remove it here — different PR, different card.


    Generated by Claude Code

  3. os-litant commented on Aug 26, 2026

    @os-litant
    Collaborator

    Dev claim (os-dev subagent, dispatched by PM round R39)

    Session: session_01UjujZN219uFzBhSYfMykCd
    Branch: claude/issue-11999-project-resolution-none (pushed, off origin/main @ db8c2881e)
    Worktree: objectstack-issue-11999

    Starting with the premise measurement named in the claim comment above (enableProjectScoping: false makes the resolution strategy moot on the standalone path). Route 2 only if the premise holds; otherwise I stop and report the fork. packages/spec/** read-only; packages/cli/src/commands/serve.ts not in surface.


    Generated by Claude Code

  4. os-litant commented on Aug 26, 2026

    @os-litant
    Collaborator

    os-dev-report

    {
      "issue": 11999,
      "status": "done",
      "branch": "claude/issue-11999-project-resolution-none",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12444",
      "premise_still_valid": true,
      "summary": "Route 2 taken; the ruling's premise was tested first and HOLDS for routing. Enumerated every reader of api.projectResolution: rest-server.registerRoutes, direct-mount-composition, and dispatcher-plugin's two `enableProjectScoping && … === 'required'` guards are ALL gated on enableProjectScoping, so with scoping false the strategy is genuinely moot for route tables. StandaloneStackResult['api'] and the single emit site now declare 'auto'; merge-boot-config's doc block and both sibling tests follow. serve.ts NOT touched (card #12151's surface) and needed no edit. packages/spec untouched. THREE CARD FIGURES FALSIFIED BY MEASUREMENT, none changing the route: (1) the card's census claim that 'none' appears ONLY on the boot path is now FALSE — #11985 MERGED as 139476877 (216 commits after the card's 7899f5745 reading), so at HEAD 'none' has 11 occurrences including packages/rest/src/rest-config-parse-not-cast.test.ts, which pins 'none' as tolerated, and the .omit() at rest-server.ts:719; every one of them is ABOUT this defect, and no independent semantic use of 'none' exists anywhere, so the blast radius is unchanged. (2) the emit site is :761-762, not the card's :760-763; serve.ts's three consumer lines are :3117/:3168/:3189, not :3086/:3088/:3139/:3160. rest-server.zod.ts:113, standalone-stack.ts:247, merge-boot-config.ts:12 and .test.ts:7 all verified exactly as the card states. (3) A SECOND DECLARED CONTRACT THE CARD DID NOT MEASURE, and the one thing that makes the premise not literally true: RestServer's discovery handler copies api.projectResolution into discovery.scoping.resolution with NO enableProjectScoping guard (verified: the only conditionals in that region gate mcp/auth/packages/datasources/email), and DiscoverySchema declares scoping.resolution as the SAME three-member enum. So GET /api/v1 on every os serve boot advertised a payload the platform's own schema rejects. This does NOT open the fork the ruling reserved: 'none' does not MEAN anything 'auto' does not — it propagates the same unrecognised string into a second enum that also lacks it. Route 1 would have to widen TWO packages/spec enums plus branch four readers; route 2 fixes both in one literal. The fallthrough the card names was re-verified and still reads as described: direct-mount-composition.ts:91 does `?? 'auto'`, rest-server.ts:3230 only compares === 'required'.",
      "tests": "All at final head 163cb3454 (= HEAD at push; every reading below taken at that commit, tree clean). Instrument proven before the census: same grep returned required=6, optional=1, auto=14, none=11, and 0 on a nonsense control. SCHEMA MEASUREMENT (executed, not read): RestApiConfigSchema 'none' REFUSED path=[\"projectResolution\"] code=invalid_value / 'auto' ACCEPTED; DiscoverySchema.scoping.resolution 'none' REFUSED path=scoping.resolution code=invalid_value / 'auto' ACCEPTED. A first attempt at this script exited MODULE_NOT_FOUND because it sat outside the workspace — that reading was discarded as NOT MEASURED, not recorded as a failure, and re-run from inside the tree. GREEN: `pnpm --filter @objectstack/cli exec vitest run src/utils/merge-boot-config.test.ts` -> 'Test Files 1 passed (1) / Tests 11 passed (11)'; `pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack.test.ts` -> 'Tests 19 passed (19)'; both packages' `run typecheck` exit 0 with no diagnostics; repo-wide `pnpm lint` (eslint . --no-inline-config, NOT narrowed) -> 'VERDICT command-exit 0' in 53s. All exit codes captured by redirect-then-capture, never after a pipe. NOT MEASURED, reported rather than glossed: packages/runtime's typecheck program does NOT include standalone-stack.test.ts (0 hits under tsc --listFiles), so 'runtime typecheck clean' says nothing about the new runtime test — it is covered by vitest and by ablation leg B. packages/cli's program DOES include its test file (1 hit), which is what makes the BOOT_API type pin live. ABLATION (direction predicted BEFORE running: red on both legs; ran with a trap on ABSOLUTE paths): reverted the runtime literal to 'none'; mutation CONFIRMED ON DISK by grepping both the injected and the removed text (injected 'none'=2, remaining 'auto'=0, was 2) — not by the editor's exit code; REBUILT @objectstack/runtime and confirmed the mutation reached the artifact with `node scripts/ablation-dist-preflight.mjs @objectstack/runtime \"projectResolution: 'none'\"` ('marker present in 2 built files'). LEG A packages/cli tsc --noEmit EXIT=1 with 'merge-boot-config.test.ts(21,79): error TS2322: Type \"auto\" is not assignable to type \"none\"'. LEG B packages/runtime vitest EXIT=1, 'Tests 1 failed | 18 passed (19)', failing case reporting code invalid_value. RESTORE LEG given equal treatment: `git checkout HEAD -- <abs path>` (never bare), proven by `git diff HEAD` empty, then REBUILT and proven absent with --absent ('marker absent from all 6 built files'), so no later reading measured the mutated tree — the source-only restore had left dist still carrying 'none', caught by checking rather than by trusting the trap. GATES: re-derived in my own worktree with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` over the real 5-path change set, not from the dispatch order. All green: check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:nul-bytes, check:changeset-gate-self-tests, check:objectui-changeset, check:published-files, check:page-declaration-shape, check:slot-lookup, check:type-check-coverage, check:type-check-debt, check:i18n, check:i18n-coverage, check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check-ci-filter-parity, check-comment-mask-adoption, check-plugin-teardown-shape, docs-audit/check-affected-docs, docs-audit/check-drift-comment, release-rehearsal-clone --self-test. check:i18n, check:i18n-coverage and check:type-check-debt first exited 1 with EXPLICIT prerequisite refusals ('Nothing was checked', 'Nothing was measured', '--re-measure cannot run') — read as NOT MEASURED, not as red — and re-ran green after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. check:engine-double-contract logged one ELIFECYCLE that was the 10-minute foreground cap landing mid-run (exit 143 on the wrapper); re-run directly, both legs exit 0 ('OK — 415 pinned, 133 in the DEBT ledger, 2 exempt'). Changeset present (.changeset/standalone-project-resolution-declared.md, patch/patch), so skip-changeset does not apply. dispatch-gates flagged STALE TREE twice (origin/main moved 5 commits mid-task); the two stale gate sources were check-skills-token-ratchet.mjs and engine-double-contract.pinned.json, neither reachable from my paths — my diff touches no skills/** and adds no fake engine double.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED — this seat cannot reach the issues API for a dedup read (GET /search/issues -> 403 'sessions are bound to their configured repositories'; GET /repos/objectstack-ai/objectstack/issues -> 403 'GitHub access is not enabled for this session'), and the dispatch order says to report rather than file blind. Reporting for PM to dedup and file. FINDING: once this PR lands, two things in packages/rest describe a state that no longer exists. (a) rest-server.ts:719 `RestApiConfigSchema.omit({ requireAuth: true, projectResolution: true })` — already known to the PM as #11985's landing note, left in place deliberately per the ruling. (b) THE INCREMENT, not covered by that note: packages/rest/src/rest-config-parse-not-cast.test.ts carries a case titled \"KEEPS `projectResolution: \\\"none\\\"` — the value this platform actually ships\" (plus supporting rationale at :34 and :209) whose premise is exactly what this card falsifies. It still PASSES after this change (it calls `construct()` directly and never reads the runtime), so nothing turns red — which is the risk: it becomes a green test asserting a rationale that is no longer true, and it names #11999 as the unsettled question. Both belong in the same follow-up that removes the omit. Outside my declared surface, and rest-server.ts is additionally held by PR #12421."
      ]
    }

    Generated by Claude Code

  5. os-litant commented on Aug 26, 2026

    @os-litant
    Collaborator

    ✅ ACCEPT (substance) — PR #12444

    PM review, domain:cli seat (#6024), R39, session session_01UjujZN219uFzBhSYfMykCd.

    Checked against GitHub, ⛔ not against the report's self-account

    criterion reading
    changed files exactly 5 — the changeset, packages/runtime/src/standalone-stack.ts, packages/cli/src/utils/merge-boot-config.ts, and their two sibling tests
    declared surface honoured exactly
    ruling 3 (packages/spec read-only) honoured — absent from the diff
    ruling 4 (serve.ts not in surface) honoured — absent, and the dev states it needed no edit, so the #12151 collision never arose
    ruling 2 (route 1 barred) honoured — no enum widened
    ACCEPT path fork get_files read directly: no governed surface. Clean → ready + queue once green

    ⭐ The premise ruling did the job it was hung for — including correcting me

    The ruling was: route 2, on the premise that enableProjectScoping: false makes the strategy moot on the standalone path; fork if 'none' genuinely means something 'auto' does not.

    The dev enumerated every reader before writing anything — RestServer.registerRoutes, mountAndRecordDirectRoutes, and the Dispatcher plugin's two enableProjectScoping && … === 'required' guards — and found all of them gated. Then it found one that is not, which the card never measured:

    RestServer's discovery handler copies api.projectResolution into discovery.scoping.resolution with no enableProjectScoping guard, and DiscoverySchema declares that field as the same three-member enum.

    ⇒ GET /api/v1 on every os serve boot advertised a payload the platform's own schema rejects. That is a user-visible defect neither the card nor this seat knew about, and it is the strongest argument for the route that was ruled.

    ⛔ This seat's premise statement was imprecise, and the measurement is what fixed it. "Moot on the standalone path" is too broad; the true statement is "moot for routing". Had the dev tested my loose phrasing instead of the fork condition I actually named, it would have been entitled to stop. It tested the named condition — does 'none' mean something 'auto' does not — found the answer is still no (the value merely propagates into a second enum that also lacks it), and proceeded. ⭐ That is the discipline the third-tier ruling exists to buy: the fork trigger has to be a specific falsifiable claim, because a vague premise fails vaguely.

    Route 1 would now have to widen two packages/spec enums and branch four readers. Route 2 fixes both with one literal. The ruling is better supported after the measurement than before it.

    Three card figures falsified, reported rather than reconciled

    1. ⚠️ The card's census is now FALSE. It claimed 'none' appears only on the boot path; fix(rest): parse the declared api config at the RestServer seam instead of casting to it #11985 merged 216 commits later, so at HEAD there are 11 occurrences — including rest-config-parse-not-cast.test.ts and the .omit() at rest-server.ts:719. Every one is about this defect and no independent semantic use exists, so the blast radius is unchanged — but the census sentence is not true any more.
    2. The emit site is :761-762, not :760-763; serve.ts's consumers are :3117/:3168/:3189, not the four lines the card lists.
    3. The second declared contract above, unmeasured by the card.

    Method

    The instrument was proven before the census was believed — the same grep returned required=6 / optional=1 / auto=14 / none=11 and 0 on a nonsense control. The schemas were executed, not read: 'none' refused with path=["projectResolution"] code=invalid_value, 'auto' accepted, on both RestApiConfigSchema and DiscoverySchema.scoping.resolution.

    ⭐ The ablation caught a trap by checking rather than by trusting. After the source-only restore, dist was still carrying the mutation — found by re-running an explicit --absent preflight over the built artifacts, not by assuming the trap had done its job. A later reading taken against that tree would have measured the mutated world while looking clean.

    check:i18n, check:i18n-coverage and check:type-check-debt each exited 1 with explicit prerequisite refusals ("Nothing was checked", "Nothing was measured") — read as NOT MEASURED, ⛔ not as red, and re-run green after building. One check:engine-double-contract exit 143 was correctly read as the 10-minute foreground cap landing mid-run — nothing ran, ⛔ not a pass and ⛔ not a failure — and re-run directly.

    The pins are anti-drift by construction, which is the part worth keeping: BOOT_API is now typed as StandaloneStackResult['api'] instead of a bare as const, so the hand-copy that let three packages disagree can no longer drift without failing tsc; and each refusal is asserted on the issue's identity (offending path + invalid_value) rather than on a bare success === false, which would have gone green on an unrelated key failing.

    Out-of-scope finding — filed by this seat

    The dev's seat could not reach the issues API for a dedup read (403 on both /search/issues and the repo issues endpoint), so it reported instead of filing blind — correct, and the fourth dev this round to hit that. Filed by this seat; see the linked card.

    ⭐ The finding is worth the filing: once this lands, rest-config-parse-not-cast.test.ts carries a case titled "KEEPS projectResolution none — the value this platform actually ships" whose premise this PR falsifies — and it still passes, because it calls construct() directly and never reads the runtime. A green test asserting a rationale that is no longer true is exactly the shape that survives unnoticed.

    Deviations

    None.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions