Skip to content

Research 004 — App foundation ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. All six [NEEDS VERIFICATION] markers in spec.md — V1 (R5), V2 (R3), V3 (R8), V4 (R7), V5 (R13), V6 (R14) — have a verdict here, so this file is complete. No [NEEDS CLARIFICATION] markers remained open in the spec. Every claim was Confirmed; none was refuted — but three confirmations carry mechanism caveats that change how a requirement is met, gathered under Spec adjustments below for the human to ratify when releasing the plan gate.

Evidence comes from four throwaway discovery spikes, each built and torn down in a mktemp directory outside the repo (per the constitution's spike rule — nothing was written to or read from the working tree). Where a claim is about the outside world it cites a document; where it is about behaviour it cites the command run and its observed output.

Verified against, on 2026-07-26, on darwin-arm64 with Node 26.5.0 / pnpm 11.15.1 / TypeScript 7.0.2 (the native "tsgo" compiler; tsc --version → 7.0.2). Per-area versions, all observed working together:

AreaPinned versions (verified)
api (Nest)@nestjs/core·common·platform-fastify 11.1.28, @nestjs/swagger 11.4.6, @nestjs/cli 11.0.24, nestjs-zod 5.5.0, zod 4.4.3, @swc/core 1.15.46, @swc/cli 0.7.9, fastify 5.10.0, reflect-metadata 0.2.2
webvite 8.1.5, @vitejs/plugin-react 6.0.4, react·react-dom 19.2.8, @pandacss/dev 1.11.5, @base-ui/react 1.6.0, orval 8.23.0, @tanstack/react-query 5.101.4, zod 4.4.3
toolingtypescript 7.0.2, vitest 4.1.10, jsdom 29.1.1

V1 — Does an SWC build emit usable decorator metadata for NestJS DI under TS 7 / Node 26, and boot on Fastify serving /health? (spec R5) ​

Question. R5 assumes the api can keep the repo's "Node runs TypeScript, tsc is noEmit" split by compiling with SWC (metadata at runtime) while TypeScript 7 does typecheck-only — and that NestJS DI, which reads constructor metadata via reflect-metadata, actually works under that arrangement on Node 26. If SWC does not emit design:paramtypes, DI fails and the whole api approach is wrong.

Verdict. Confirmed. SWC emits the metadata Nest DI needs; the app boots on Fastify and serves /health. The exact mechanism is now pinned (see Evidence), and TypeScript 7 accepts the decorator compiler options for typecheck-only (V-detail below).

Evidence.

  • Compiling swc src -d dist --strip-leading-paths with an explicit .swcrc (jsc.parser.decorators: true, jsc.transform.legacyDecorator: true, jsc.transform.decoratorMetadata: true) produced _ts_metadata("design:paramtypes", [HealthService]) on the compiled controller constructor — the exact symbol Nest DI reads.
  • node dist/main.js booted on @nestjs/platform-fastify 5.10.0 under Node 26.5.0 with no "Nest can't resolve dependencies" error; curl http://127.0.0.1:<port>/health → HTTP/1.1 200 OK, content-type: application/json; charset=utf-8, body {"status":"ok"}, and the body came from the injected HealthService.getStatus() — proving DI resolved.
  • TS 7 accepts the decorators (R5's typecheck half): experimentalDecorators and emitDecoratorMetadata both appear unflagged in tsc --showConfig, and tsc --noEmit over the decorator/DI source exits 0. --noEmit never emits metadata regardless, so runtime metadata stays SWC's job — matching the intended split. (microsoft/typescript-go#2343, Dec 2025, added the options.)

Caveat. The run mechanism must be the SWC CLI + explicit .swcrc, not nest build -b swc. The Nest CLI's SWC builder runs an internal pnpm install deps-check that pnpm 11.15 exits non-zero on (ERR_PNPM_IGNORED_BUILDS for @swc/core); invoking swc directly sidesteps it and is more explicit. See F9 for the build-approval gate this touches. Whichever is chosen, DI depends on jsc.transform.decoratorMetadata being on.

V2 — Do nestjs-zod + @nestjs/swagger emit a faithful OpenAPI 3 document from Zod, offline? (spec R3) ​

Question. R3 assumes a single Zod schema can be the source of truth for both runtime validation and the OpenAPI document — no hand-authored DTO duplicating the shape — and that the document can be written to a static openapi.json without the HTTP server listening (so codegen/CI need no running server).

Verdict. Confirmed. The document is generated offline from a Nest application context and the response schema is derived faithfully from Zod. The wiring API differs from most published examples (see Caveat / Spec adjustments).

Evidence.

  • SwaggerModule.createDocument run against a Nest app context on the Fastify adapter without .listen(), then writeFileSync, produced an OpenAPI 3.0.0 openapi.json containing GET /health with responses.200.content['application/json'].schema.$ref → #/components/schemas/HealthResponseDto_Output, whose schema is { "type":"object", "properties":{ "status":{ "type":"string", "enum":["ok"] } }, "required":["status"], "additionalProperties":false }.
  • z.literal('ok') became { type:"string", enum:["ok"] } — the Zod constraint survived into the document, no separate DTO shape written.

Caveat. nestjs-zod v5 dropped patchNestjsSwagger (which nearly every tutorial still shows). The v5 wiring is: annotate routes with @ZodResponse({ status, type }) and post-process with cleanupOpenApiDoc(SwaggerModule.createDocument(...)) — cleanupOpenApiDoc is required for correct output. Under Zod 4, schemas are emitted via z.toJSONSchema and named with an _Output / _Input suffix (input/output split); the response $ref uses the _Output variant. The plan must be written against the v5 API, not the older docs.

V3 — Does Orval consume that OpenAPI document into TanStack Query hooks + Zod validators that typecheck under TS 7? (spec R8) ​

Question. R8 assumes Orval turns the api's OpenAPI document into a typed client, TanStack Query v5 hooks, and Zod response validators, and that the generated code compiles under TypeScript 7 in a React 19 project.

Verdict. Confirmed. Orval 8.23 generates all three; the output typechecks clean under TS 7.0.2 and produces a passing Vite build in a React 19 + react-query 5 project, with zero any in the generated code.

Evidence.

  • Two Orval output entries against one openapi.json — client: 'react-query' (httpClient: 'fetch') and client: 'zod' (fileExtension: '.zod.ts') — generated useGetHealth (a typed useQuery wrapper with a typed queryKey), useGetUser(id: string) (path param typed), useCreateUser() (a useMutation, Orval's natural POST behaviour), and standalone Zod validators GetHealthResponse = z.object({ status: z.string() }), GetUserResponse with z.uuid()/z.email()/ z.enum([...])/z.iso.datetime — OpenAPI constraints (format, enum, minLength) survived into the validators.
  • tsc --noEmit (TS 7.0.2) over a component consuming the hooks and asserting a validator's inferred type is assignable to the generated model → exit 0. vite build → success (144 modules). grep any over generated code → 0.
  • Sources: orval.dev/docs/guides/react-query, orval.dev/docs/guides/client-with-zod.

Caveat. Two entries are required — no single Orval mode emits both hooks and validators. The Zod validators are NOT auto-wired into the hooks (generated fetch does a plain JSON.parse with no runtime check). See F10: this gives the src/api facade (R9) a concrete job — apply the validators — rather than assuming Orval validates responses for us. Orval needs an operationId on every operation to name the hooks; @nestjs/swagger emits these. Orval 8.23 emits Zod 4 syntax, so zod must be pinned to 4.x.

V4 — Does Base UI cover the primitives we need on React 19, and does Panda CSS work under Vite + TS 7? (spec R7) ​

Question. R7 assumes Base UI (the base-ui.com library) provides Dialog, Popover, Tooltip, Tabs, and Select at a stable-enough version on React 19, and that Panda CSS's codegen integrates with the repo's Vite + TypeScript 7 setup.

Verdict. Confirmed for both. Base UI covers all five primitives at a stable release on React 19; Panda's codegen and generated types compile under TS 7 and build under Vite. No Radix/Ark fallback is warranted.

Evidence.

  • @base-ui/react 1.6.0 (a stable post-1.0 release) declares peer react: "^17 || ^18 || ^19". All five imports resolved and typechecked under TS 7 and built under Vite: Dialog, Popover, Tooltip, Tabs, Select (each with the expected sub-parts). Docs: base-ui.com/react/overview/quick-start, base-ui.com/react/components/select.
  • Panda via PostCSS (postcss.config.cjs → { '@pandacss/dev/postcss': {} }): panda codegen generated styled-system/ (css, styled, tokens, patterns, .d.ts). tsc --noEmit (TS 7.0.2) over the app including the generated types → exit 0. vite build → success, CSS emitted (15.6 kB) containing the authored Panda classes (.fs_lg, .bg_blue\.100, .p_4, .c_red\.500).

Caveat. Package-name trap (F12): install @base-ui/react — not @base-ui-components/react (renamed, frozen at an RC) and not @mui/base (deprecated). Panda has no Vite plugin (@pandacss/vite-plugin 404s); the integration is PostCSS. Lock in panda.config.tsjsxFramework: "react", add styled-system to the app tsconfig include, a @layer declaration in the CSS entry, and a prepare: "panda codegen" script.

V5 — Can one root typecheck cover packages/** plus two divergent app profiles under TS 7? (spec R13) ​

Question. R13 assumes the repo's single root typecheck survives the arrival of two apps with conflicting compiler profiles (web: DOM + JSX; api: decorators), wired via project references, with each profile isolated to its own tsconfig.

Verdict. Confirmed that the goal is reachable — one root command, options isolated per app, under TS 7 — but the mechanism should change. Project references work under TS 7 yet force emit, which breaks the repo's noEmit posture; a per-project tsc --noEmit behind one script is the better fit. See Spec adjustments (R13 wording) and F7/F8.

Evidence.

  • TS 7 supports both mechanisms: native --build/project references landed Nov 2025, and experimentalDecorators/emitDecoratorMetadata in typescript-go#2343 (Dec 2025). Sources: devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025, github.com/microsoft/typescript-go/pull/2343.
  • Recommended — per-project --noEmit, one script: tsc --noEmit -p packages/domain && tsc --noEmit -p apps/web && tsc --noEmit -p apps/api, each project extends tsconfig.base.json. A planted const _bad: string = 123; in each of the three trees, one at a time, made the single script exit 1 with TS2322 and the right file; a clean run exits 0. Isolation proven: DOM document under the api profile → TS2584 (fails), under web → passes; a decorator under web's profile → TS1241 (fails), under api → passes.
  • Project references (works, heavier): a solution-style root config with references to three composite projects gave one tsc --build command that also caught each planted error — but --build/composite may not disable emit (TS6310); it forced emitDeclarationOnly → .d.ts + .tsbuildinfo on disk and shifted the referenced package's resolution from source to built declarations via exports.types. That contradicts the repo's source-not-builds / noEmit model (monorepo.md), buying only cross-project incremental builds we do not need yet.

Caveat. See F8 — TypeScript's default lib includes DOM, so without an explicit non-DOM lib in the base config, document/window resolve everywhere and the isolation above silently breaks.

V6 — Does a single Vitest run cover a node-env app suite and a jsdom-env app suite? (spec R14) ​

Question. R14 assumes one root vitest run can execute the api's tests in a Node environment and the web's in a DOM environment alongside the existing packages/** / tests/** suites.

Verdict. Confirmed. A single vitest.config.ts using the test.projects key runs all suites in their correct environments in one invocation.

Evidence.

  • test.projects: [ {name:'domain', environment:'node'}, {name:'api', environment:'node'}, {name:'web', environment:'jsdom'} ] (each with its own root/include). One vitest run attributed each file to its project/env in the verbose reporter (|api| node, |web| jsdom), all passing. Environments genuinely differ: a document.createElement/window test passes under jsdom and throws ReferenceError: document is not defined when forced into a node project. A failing test in any project fails the single run (exit 1); removing it returns exit 0.
  • jsdom 29.1.1 must be a root dev dependency. The old vitest.workspace file is deprecated; the test.projects array replaces it.

F7 — TypeScript 7 removed moduleResolution: "node" and baseUrl ​

Finding. A typical NestJS tsconfig uses moduleResolution: "node" (node10) and baseUrl. TS 7 removed both. They were the only two errors the api spike hit; the fix is moduleResolution: "nodenext" (or bundler) and replacing baseUrl with paths: { "*": ["./*"] }. After that, the decorator code typechecks cleanly.

Why it matters. The api's tsconfig (R13) and any copied Nest boilerplate must be migrated; budget a small tsconfig-migration step in the plan. This is a general TS 7 fact, a graduation candidate for docs/architecture/typescript.md.

F8 — The base tsconfig must pin a non-DOM lib, or DOM leaks everywhere ​

Finding. TypeScript's default lib includes DOM. If tsconfig.base.json leaves lib unset, document/window/etc. resolve in packages/** and in apps/api — so the "each profile isolated" guarantee of R13 is violated invisibly (no error where there should be one). The base must pin "lib": ["es2023"] and apps/web must override to add "dom", "dom.iterable".

Why it matters. Directly conditions R13/V5. Without it, an api file could use a browser global and typecheck green, defeating the point of separate profiles. Belongs in the plan as an explicit requirement and graduates to monorepo.md.

F9 — pnpm's build-approval gate must be extended for @swc/core ​

Finding. pnpm 11.15's strictDepBuilds makes an un-approved native build script fail the install (ERR_PNPM_IGNORED_BUILDS). The api needs @swc/core (a native binary) built; this must be approved the same way esbuild already is. pnpm-workspace.yaml currently carries allowBuilds: { esbuild: true } (the current pnpm-11 setting; per research 003 V1 it replaced onlyBuiltDependencies). Add @swc/core (and, in the web spike, esbuild was already needed) to that allow-list, else pnpm install and even pnpm <bin> invocations exit non-zero.

Why it matters. A setup step for the plan; without it the api build and CI install fail. Extends the existing spec-003 finding, so it slots into the same allowBuilds mechanism rather than a new one.

F10 — Orval's Zod validators are decoupled from its hooks ​

Finding. Orval emits Query hooks and Zod validators as separate artifacts; the hooks do not call the validators (plain JSON.parse). Runtime response validation therefore has to be applied by hand.

Why it matters. This gives the src/api facade (R9) a real responsibility beyond re-exporting: it is where a generated hook and its matching validator are composed, so components get runtime-checked data. Strengthens the facade convention rather than weakening it; the plan should specify the facade wraps hooks with their validators where response integrity matters (prices, account state later — for the skeleton /health, a single wrapped example proves the pattern).

F12 — Base UI package-name trap ​

Finding. Three similarly-named packages exist: @base-ui/react (correct, 1.6.0, stable), @base-ui-components/react (the old name, frozen at 1.0.0-rc.0), and @mui/base (deprecated). Only the first is the current base-ui.com library.

Why it matters. A wrong install would pin dead code. The plan must name @base-ui/react explicitly. (F11 — the nestjs-zod v5 API drift — is recorded in V2's caveat; F12 numbered to stay clear of it.)

Refuted claims ​

None. All six verification markers were confirmed. Three confirmations carry mechanism changes (below), but no premise the spec rests on was found false, so nothing returns to step 1.

Accepted implementation deviations (recorded at step 6) ​

Surfaced during implementation (.superpowers/sdd/progress.md), triaged by whole-branch review, and ratified — none reopens discovery or the spec:

  • tests/monorepo/wiring.test.ts (a spec-002 test), edited twice. (1) The "no rival linter/formatter" check was narrowed from a lockfile substring scan to a precise dependencies/devDependencies-key match across every workspace manifest — needed because @pandacss/dev pulls prettier in transitively for its own generated-code formatting (spec 004), which is not a rival to Biome and should not fail the check. (2) lib was removed from PROFILE_KEYS (the list of keys forbidden in the shared base) and a new assertion pins the base's lib to ["ES2023"] — the F8 non-DOM floor. Both edits sharpen spec 002's original invariant rather than weaken it; ratified sound by whole-branch review.
  • apps/api/src/generate-openapi.cli.ts split out from generate-openapi.ts, not listed in plan.md's File Structure. generate-openapi.ts stays a side-effect-free module (importable from generate-openapi.test.ts offline, V2); the CLI file is the thin CJS/ESM-gated entry point that actually writes openapi.json. Accepted by controller review as a mechanical split, not a scope change.

Graduated (step 6) ​

The candidates listed under Graduation below were moved to docs/architecture/ on 2026-07-26:

  • TS 7 tsconfig facts (F7, F8, and the decorator typecheck-only split from V1) → docs/architecture/typescript.md (new "TypeScript 7 (tsgo) facts" section).
  • One-typecheck/one-test topology (V5, V6) → docs/architecture/monorepo.md ("One config, one typecheck, one test run", rewritten to match the arrived apps; also reconciles that section's prior "base holds only strictness/syntax" statement with F8's added lib floor).
  • The api build model (V1, F9) and the contract pipeline (V2, V3, F10) → docs/architecture/stack.md (new "The api build model" and "The contract pipeline" sections).

This file remains the historical record of how those facts were established (spikes, evidence, verdicts); the architecture docs are the durable reference for the next spec.

Spec adjustments arising from discovery (for human ratification at the plan gate) ​

The spec is approved; these are recommended amendments surfaced by discovery, not applied silently. Please ratify (or reject) them when releasing the plan gate — the plan will be written to match whatever is decided.

  1. R13 mechanism — replace "project references" with "per-project tsc --noEmit behind one root script." Project references work under TS 7 but force emit (.d.ts + .tsbuildinfo) and break the repo's noEmit/source-resolution model; the per-project script gives the same one-command guarantee with no build artifacts (V5).
  2. R13 addition — pin "lib": ["es2023"] in tsconfig.base.json, with apps/web adding dom, dom.iterable. Required for the profile isolation R13 promises to actually hold (F8).
  3. R5 mechanism — pin "SWC CLI + explicit .swcrc" as the api build, rather than leaving nest build -b swc open; the CLI path collides with pnpm's build-approval gate (V1/F9). R5 already says "or equivalent," so this is a narrowing, not a reversal.

Implementation-level facts that need no spec change but bind the plan: nestjs-zod v5 API (@ZodResponse + cleanupOpenApiDoc, _Output suffixes) (V2); Orval two-entry config + facade-applied validators (V3/F10); @base-ui/react package name and Panda-via-PostCSS (V4/F12); the TS 7 tsconfig migration off moduleResolution:"node"/baseUrl (F7); adding @swc/core to allowBuilds (F9); all version pins in the table above.

Graduation ​

Done (2026-07-26) — see "Graduated (step 6)" above for the exact destinations. Candidates as originally identified, moved to docs/architecture/ at step 6 so the next spec does not re-derive them:

  • TS 7 tsconfig facts → typescript.md: moduleResolution:"node" and baseUrl removed (F7); base must pin a non-DOM lib (F8); experimentalDecorators/emitDecoratorMetadata supported for typecheck-only.
  • Monorepo one-typecheck / one-test topology → monorepo.md: per-project tsc --noEmit behind one script (not project references, and why) (V5); Vitest test.projects with per-env projects, jsdom at root (V6).
  • The api build model → stack.md (or a new api.md): SWC CLI + .swcrc (legacyDecorator, decoratorMetadata) as the api's compile step, the documented divergence from the root no-build model; @swc/core on the pnpm allowBuilds list (F9).
  • The contract pipeline → stack.md: Zod → nestjs-zod v5 (@ZodResponse + cleanupOpenApiDoc) → OpenAPI → Orval (two-entry, fetch client) → src/api facade that applies validators (V2/V3/F10).