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:
| Area | Pinned 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 |
| web | vite 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 |
| tooling | typescript 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-pathswith 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.jsbooted on@nestjs/platform-fastify5.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 injectedHealthService.getStatus()— proving DI resolved.- TS 7 accepts the decorators (R5's typecheck half):
experimentalDecoratorsandemitDecoratorMetadataboth appear unflagged intsc --showConfig, andtsc --noEmitover the decorator/DI source exits 0.--noEmitnever 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.createDocumentrun against a Nest app context on the Fastify adapter without.listen(), thenwriteFileSync, produced an OpenAPI3.0.0openapi.jsoncontainingGET /healthwithresponses.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') andclient: 'zod'(fileExtension: '.zod.ts') — generateduseGetHealth(a typeduseQuerywrapper with a typedqueryKey),useGetUser(id: string)(path param typed),useCreateUser()(auseMutation, Orval's natural POST behaviour), and standalone Zod validatorsGetHealthResponse = z.object({ status: z.string() }),GetUserResponsewithz.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 anyover 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/react1.6.0 (a stable post-1.0 release) declares peerreact: "^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 codegengeneratedstyled-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, andexperimentalDecorators/emitDecoratorMetadatain 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 projectextends tsconfig.base.json. A plantedconst _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: DOMdocumentunder 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
referencesto threecompositeprojects gave onetsc --buildcommand that also caught each planted error — but--build/composite may not disable emit (TS6310); it forcedemitDeclarationOnly→.d.ts+.tsbuildinfoon disk and shifted the referenced package's resolution from source to built declarations viaexports.types. That contradicts the repo's source-not-builds /noEmitmodel (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 ownroot/include). Onevitest runattributed each file to its project/env in the verbose reporter (|api| node,|web| jsdom), all passing. Environments genuinely differ: adocument.createElement/windowtest passes under jsdom and throwsReferenceError: document is not definedwhen forced into a node project. A failing test in any project fails the single run (exit 1); removing it returns exit 0.jsdom29.1.1 must be a root dev dependency. The oldvitest.workspacefile is deprecated; thetest.projectsarray 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/devpullsprettierin transitively for its own generated-code formatting (spec 004), which is not a rival to Biome and should not fail the check. (2)libwas removed fromPROFILE_KEYS(the list of keys forbidden in the shared base) and a new assertion pins the base'slibto["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.tssplit out fromgenerate-openapi.ts, not listed inplan.md's File Structure.generate-openapi.tsstays a side-effect-free module (importable fromgenerate-openapi.test.tsoffline, V2); the CLI file is the thin CJS/ESM-gated entry point that actually writesopenapi.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 addedlibfloor). - 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.
- R13 mechanism — replace "project references" with "per-project
tsc --noEmitbehind one root script." Project references work under TS 7 but force emit (.d.ts+.tsbuildinfo) and break the repo'snoEmit/source-resolution model; the per-project script gives the same one-command guarantee with no build artifacts (V5). - R13 addition — pin
"lib": ["es2023"]intsconfig.base.json, withapps/webaddingdom,dom.iterable. Required for the profile isolation R13 promises to actually hold (F8). - R5 mechanism — pin "SWC CLI + explicit
.swcrc" as the api build, rather than leavingnest build -b swcopen; 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"andbaseUrlremoved (F7); base must pin a non-DOMlib(F8);experimentalDecorators/emitDecoratorMetadatasupported for typecheck-only. - Monorepo one-typecheck / one-test topology →
monorepo.md: per-projecttsc --noEmitbehind one script (not project references, and why) (V5); Vitesttest.projectswith per-env projects,jsdomat root (V6). - The api build model →
stack.md(or a newapi.md): SWC CLI +.swcrc(legacyDecorator,decoratorMetadata) as the api's compile step, the documented divergence from the root no-build model;@swc/coreon the pnpmallowBuildslist (F9). - The contract pipeline →
stack.md: Zod → nestjs-zod v5 (@ZodResponse+cleanupOpenApiDoc) → OpenAPI → Orval (two-entry, fetch client) →src/apifacade that applies validators (V2/V3/F10).