Platform v2 — Phase 1 Implementation Plan
Status: Phase 1 implementation complete on branch ralph/platform-v2 (PR #701, not merged). Scope reduced per ADR-017: Phases 2–6 (bulk route migration, frontend rewrite, edge layer, cutover, docs) are cancelled. PR #701 will be split into a substrate-only PR (deps + ESM) and an optional framework PR (defineRoute + 10 reference routes + service restructures). v2 adoption is opportunistic on greenfield work only; coexistence with v1 is permanent.
This plan is preserved as the historical record of Phase 1's intended scope. Items below describe what shipped on ralph/platform-v2. Future work is governed by ADR-017, not by the "Phases 2–6 are tracked separately" sentence below.
Implements spec.md Phase 1: build the v2 framework on top of an upgraded dep tree and pure-ESM module system, then validate it end-to-end on 10 representative routes. Phases 2–6 (bulk route migration, frontend rewrite, edge layer, cutover, docs) are tracked separately and not part of this plan.
Goal
Build the v2 framework from scratch — defineRoute + predicates + output kinds + error model + observability + codegen + lint rules — and validate it end-to-end on 10 representative routes covering the hard cases, on top of an upgraded dep tree and pure ESM module system.
Status
Block A — Pre-flight: dep tree + ESM flip
- [x] 1. Add
"type": "module"to all 8 package.json files - [x] 2. Replace
__dirnamewithfileURLToPathshim in ~10 scripts - [x] 3. Replace dynamic
require('crypto')with static import inStripeService.ts:765 - [x] 4. Upgrade pnpm 8 → 11; migrate config from
package.json.pnpmtopnpm-workspace.yaml - [x] 5. Bump Node engines 22 → 24 LTS
- [x] 6. Upgrade TypeScript 5.7 → 6.0
- [x] 7. Upgrade Stripe SDK 18 → 22
- [x] 8. Upgrade Twilio SDK 5 → 6
- [x] 9. Upgrade Vitest 3 → 4 across 5 vitest configs
- [x] 10. Run Tailwind 3 → 4 auto-migration tool on apps/api
- [x] 11. Visual QA each of 32 email templates after Tailwind 4 migration
- [x] 12. Bump minor versions: next, react, zod, sentry, react-query, biome, turbo, upstash/ratelimit, playwright, supabase, tsx
- [x] 13. Align
@types/node22 in apps/web; align@hookform/resolvers5.2; alignlucide-react - [x] 14. Remove
prisma-extension-soft-delete,node-fetch,swagger-jsdoc,@types/swagger-ui-express,axios - [x] 14a. Migrate
vi.fn()arrow constructor mocks to function-form for vitest 4 (~99 test files across api+web; vitest 4 throwsTypeError: arrow is not a constructoronnewwhen factory is an arrow) - [x] 14b. Migrate remaining arrow-factory constructor-mock variants missed by 14a: (a)
vi.mocked(X).mockImplementation(() => ({...}))— 11 test files; (b) shared helpers inapps/api/tests/support/consolidated/mocks/{stripe,acuity,email}.mock.tsexportingconst mockImplementation = () => ({...})consumed by ~10 stripe/reservations/tours tests - [x] 14c. Migrate further arrow-factory constructor-mock variants surfaced by item 15 third retry (16 test files / 94 tests, multi-variant): (a) cast-form
(X as any).mockImplementation(() => <expr>)and(X as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => <expr>)— appears in ~9 files including all 6apps/api/tests/api/stripe/stripe.{setup-intent,reservation-payment,payment-method-attached}-webhook.api.test.ts, plusacuity.availability.api.test.ts,reservations/reservation.acuity-sync.api.test.ts(multi-line arrow form),unit/services/user/WaitlistEntryService.unit.test.ts; (b) pre-bound variableMockedStripeService.mockImplementation(() => <expr>)whereMockedStripeServicewas destructured/rebound fromvi.mocked(...)— appears in 5+ files includingstripe.{customer-portal,subscription-webhooks,subscription-renewal,subscription-lifecycle,referral-code-subscription-lifecycle}.api.test.tsandinsurance/insurance-prices.api.test.ts; (c) annotated arrowmockImplementation((): SomeType => (<expr>) as SomeType)—tests/api/stripe/stripe.webhook-idempotency.api.test.ts - [x] 14d. Update Stripe API fixture shapes in checkout preview tests for dahlia restructuring (item-7 follow-up; 3 files):
tests/api/checkout/{preview-discount,preview-org-dedicated-room-discount,waitlist.checkout}.api.test.tsmockgetCustomer/getPromotionCodeto return basil shapediscount.coupon/promotionCode.coupondirectly, but item 7 migrated production code to dahlia shapediscount.source.coupon/promotionCode.promotion.coupon. Fix: update fixture object shapes in the 3 files to match dahlia API - [x] 14e. Fix AuthContext impersonation init race surfaced by dep-upgrade chain (item 12: react 19.2.6 / @tanstack/react-query 5.100.9 / vitest 4 microtask scheduling). Failure:
apps/web/src/contexts/AuthContext.test.tsx > AuthProvider > uses impersonation profile when impersonating— expectedresult.current.user?.id'target-user-123', got'admin-user'. Race: AuthProvider's useEffect firesinit()(calls asyncinitImpersonationwhich awaitsfetch(profile)); then synchronously subscribes tosupabase.auth.onAuthStateChangewhose mocked callback fires sync with admin session and sets user=admin (impersonatingRef still false). Test'sawait waitFor(() => result.current.user).not.toBeNull()satisfies on user=admin and asserts beforeinitImpersonation's fetch microtask resolves to overwrite with target. Fix: test-side — replaceawait waitFor(() => expect(result.current.user).not.toBeNull())withawait waitFor(() => expect(result.current.user?.id).toBe('target-user-123'))so the waiter blocks until impersonation completes. Production-code race exists but is contained by theimpersonatingRef.currentguard at AuthContext.tsx:163 (subsequent auth events are ignored once impersonating). No production-code change needed; tighten the test's wait condition. Files:apps/web/src/contexts/AuthContext.test.tsx(1 site, line ~409). Validation:pnpm --filter @mg/web test src/contexts/AuthContext.test.tsx→ 17/17 passes (16/17 today, this single test fails) - [x] 14f. Fix TimeSelector pricing-display test brittleness exposed by @heroui/listbox minor bump (item 12 / @heroui/* version drift). Failure:
apps/web/src/components/molecules/scheduling/TimeSelector.test.tsx > pricing display > should display hourly prices when pricing props are provided—TestingLibraryElementError: Found multiple elements with the text: ... element?.tagName === "SPAN" && element?.textContent === "$21/hr". HeroUI now wrapsListboxItem.endContentin<span data-slot="endContent">…</span>; previously was a<div>(or unwrapped). The test's matcher matches BOTH the outer<span data-slot="endContent">(textContent contains "$21/hr" because nested) AND the inner price<span class="text-sm font-medium text-default-600 tabular-nums">(exact "$21/hr"). Production rendering is correct — the price is rendered once via theendContentprop; the outer wrap is HeroUI's slot envelope. Fix: test-side — make the matcher specific to the inner price span (assert by classtext-default-600.tabular-numsor usegetAllByText+expect(matches).toHaveLength(2), or restrict to the option's children scope). Files:apps/web/src/components/molecules/scheduling/TimeSelector.test.tsx(1 site, line ~240). Validation:pnpm --filter @mg/web test src/components/molecules/scheduling/TimeSelector.test.tsx→ green for all subtests (currently 1 failure) - [x] 15. Verify all dep upgrades land green (install + typecheck + full v1 test suite)
Block B — Framework primitives, types, registries
- [x] 16. Create
apps/api/src/lib/http/directory + module exports skeleton - [x] 17. Define
RouteSpec,RouteContext,OutputKind,AuthPredicatetypes - [x] 18. Implement
RouteErrorfactories:notFound,unauthenticated,forbidden - [x] 19. Implement
RouteErrorfactories:validation,conflict,unprocessable,paymentRequired - [x] 20. Implement
RouteErrorfactories:rateLimited,maintenance,externalUnavailable,internal - [x] 21. Implement
RouteError.businessRule+toProblemDetails()RFC 9457 serializer - [x] 22. Create
BusinessRuleKindtyped registry - [x] 23. Create
ExternalServicetyped registry - [x] 24. Create
AuditSourcetyped registry - [x] 25. Implement
defineRouteshell with discriminated-union type signature
Block C — Output kinds
- [x] 26.
ok(schema)output kind - [x] 27.
created(schema)withLocationheader - [x] 28.
list(itemSchema, opts)with cursor + optionalwithTotal - [x] 29.
empty()(204) - [x] 30.
redirect({ to, sameOriginOnly, status? })with allowlist - [x] 31.
file({ contentType, filename }) - [x] 32.
stream({ contentType }) - [x] 33.
twiml()
Block D — Auth predicates
- [x] 34.
requires.public()+requires.user.authenticated()(+x-impersonateheader handling) - [x] 35.
requires.user.admin(),staff(),staffAt(),role() - [x] 36.
requires.user.owns(resource, idFn, opts?)withdeletedAt: nulldefault - [x] 37.
requires.user.belongsToOrgOf() - [x] 38.
requires.cron() - [x] 39.
requires.webhook.stripe()wrappingstripe.webhooks.constructEvent - [x] 40.
requires.webhook.twilio()wrappingvalidateRequest - [x] 41.
requires.webhook.slack()wrapping local HMAC helper - [x] 42.
requires.webhook.supabase()shared-secret check - [x] 43. Composers:
requires.all,requires.any,requires.not - [x] 44. Predicate entity-cache infrastructure (per-request map)
Block E — Cross-cutting concerns
- [x] 45. HMAC-signed cursor encode/decode (
lib/http/cursor.ts) - [x] 46.
IdempotencyKeyPrisma model + migration - [x] 47. Idempotency middleware reading typed
keyFromenum - [x] 48. Rate limiter (Upstash) with global default + per-route override
- [x] 49. CSRF: SameSite + double-submit token (
mg-csrfcookie +X-CSRF-Tokenheader) - [x] 50. CORS handler with per-route auth-aware origin matching
- [x] 51. OPTIONS preflight handler (centralized)
- [x] 52.
Cache-Control: no-storedefault + per-route opt-out via output kindcacheoption - [x] 53. Zod issue → RFC 9457 validation-error mapping
- [x] 54. Audit context wiring (use existing
AsyncLocalStorage+ 4 GUCs)
Block F — Observability
- [x] 55.
ctx.loggerwriting JSON to stdout with correlation fields - [x] 56. Request ID generation +
x-req-idheader pass-through - [x] 57.
ctx.tracewrapping Sentry span API - [x] 58. Framework error catch → Sentry for 5xx; INFO log for 4xx
Block G — Adapters
- [x] 59. Next App Router adapter
- [x] 60. Test adapter (in-process, no HTTP)
- [x] 61. Route shim codegen from registry →
apps/api/src/app/api/**/route.ts
Block H — Test helpers
- [x] 62.
invoke(route, args)test helper - [x] 63.
invokeExpectingError(route, args)test helper - [x] 64.
testUser,testStaff,testAdmin,testImpersonatorauth factories
Block I — Frontend codegen
- [x] 65. Generate typed frontend client →
packages/api-client/src/generated.ts - [x] 66. Generate React Query hooks:
useApi,useApiList,useApiMutation - [x] 67. Client-side
ApiError+ 429/503 retry withRetry-After
Block J — Lint rules
- [x] 68.
no-raw-responseripgrep pre-commit hook - [x] 69.
no-route-export-bypassripgrep pre-commit hook - [x] 70.
no-implicit-hard-deleteripgrep pre-commit hook - [x] 71.
no-side-effects-in-patchripgrep pre-commit hook
Block K — Service signature adaptations (for Phase 1 routes)
- [x] 72. Rename
AppError→DomainErroracross services + addDomainError.kindregistry - [x] 73. Adapt
UserServicesignatures (dropreq, takeauditContext+tx) - [x] 74. Adapt
StripeServicefor webhook + cron usage - [x] 75. Adapt
FinanceServicefor CSV export usage - [x] 76. Adapt
ReservationService(forgetReservation) - [x] 77. Adapt
ContactService(forcontactForm)
Block L — Permissions starter set
- [x] 78.
permissions/users.ts(canApproveUsers, canViewUser) — dead funcs canBanUsers/canEditUser removed during pass-1 simplify - [x] 79.
permissions/reservations.ts(canViewReservation) — dead funcs canCancelReservation/canCheckInReservation removed during pass-1 simplify - [x] 80.
permissions/locations.ts— file deleted during pass-1 simplify (all exports were dead)
Block M — 10 representative routes
- [x] 81.
getUser—GET /users/:id(entity + owner-or-staff) - [x] 82.
listUsers—GET /users(cursor +withTotal, staff-only) - [x] 83.
approveUser—POST /users/:id/actions/approve - [x] 84.
stripeWebhook(a) — route declaration + signature predicate integration - [x] 85.
stripeWebhook(b) — dispatcher adapted toctx.auth.webhook.event+ idempotency wiring - [x] 86.
stripeWebhook(c) — tests: happy path, replay, invalid signature - [x] 87.
syncStripeCron—POST /cron/sync-stripe-and-refresh(cron auth, preview skip) - [x] 88.
exportFinance—GET /finance/exports(file output, staff-only) - [x] 89.
oauthCallback—GET /public/auth/oauth-callback(redirect, sameOriginOnly, public) - [x] 90.
docsAsk—POST /docs/ask(streaming output, staff-only) - [x] 91.
getReservation—GET /reservations/:id(owner-scoped predicate, loads entity) - [x] 92.
contactForm—POST /public/contact(Turnstile + side effects)
Block N — Validation + spec follow-up
- [x] 93. Phase 1 validation gate — all 10 routes work end-to-end (HTTP, DB, tests, frontend hook); lint rules CI-enforced; typecheck + tests green
- [x] 94. Spec follow-up — close Phase 1 divergences in spec; update audit's status pointer
Driving citations
- Spec Architectural commitments — 8 locked decisions that the framework must honor
- Spec The defineRoute API — authoring surface
- Spec Output kinds — discriminated union for response shapes
- Spec Auth predicates — composable predicates with entity caching
- Spec Error model — RFC 9457 + typed registries
- Spec Idempotency — typed
keyFrom+IdempotencyKeytable - Spec CSRF protection — SameSite + double-submit token
- Spec Audit context — typed
AuditSource+ 4-GUC mechanism
Work breakdown
1. Add "type": "module" to all 8 package.json files
- Files:
apps/api/package.json,apps/web/package.json,package.json,packages/*/package.json(5) - Add
"type": "module"field; verify nothing breaks immediately - Spec coverage: pre-req for Architectural commitments
2. Replace __dirname with fileURLToPath shim in ~10 scripts
- Files:
apps/api/scripts/{check-pending-migrations,query-pending-migrations,deploy-migrations,seed-from-production,export-assets,compare-migration-state,seed-e2e}.tsand similar - Standard ESM shim:
const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename)
3. Replace dynamic require('crypto') with static import
- File:
apps/api/src/services/payment/StripeService.ts:765 - Replace
require('crypto').randomBytes(2)withimport crypto from 'node:crypto'at top
4. Upgrade pnpm 8 → 11 + migrate workspace config
- Bump pnpm version in
package.jsonengines + lockfile regeneration - Move
pnpmfield frompackage.jsontopnpm-workspace.yaml(silent-ignore in v11) - Rename
npm_config_*env vars topnpm_config_* - Migrate
onlyBuiltDependenciesetc. to newallowBuildsfield
5. Bump Node engines 22 → 24 LTS
- Files: root
package.jsonengines field, Vercel project settings - No code changes; pure runtime bump
6. Upgrade TypeScript 5.7 → 6.0
- Bump
typescriptdep; fix any strict-default surface issues (we already havestrict: true) - Confirm
typesdefault-empty change doesn't break@types/nodepickup
7. Upgrade Stripe SDK 18 → 22
- Bump
stripedep; verify nodecimal_string/StripeContext/Stripe.errors.StripeErrorusage (confirmed clean by audit) - Codebase doesn't touch the v22-breaking API surface; this is essentially a version bump
8. Upgrade Twilio SDK 5 → 6
- Bump
twiliodep; verify only breaking change (Node ≥20) is met - Affects 2 files:
SmsService.ts,webhooks/twilio/incoming/route.ts
9. Upgrade Vitest 3 → 4 across 5 vitest configs
- Files:
vitest.config.ts,vitest.ci.config.ts,vitest.db-intensive.config.ts,vitest.path-config.ts,vitest.unit.config.ts - Bump Vite to ≥6
- Audit
vi.mock/vi.restoreAllMocks/vi.fn().getMockNameusage for new semantics
10. Run Tailwind 3 → 4 auto-migration tool on apps/api
- Run
npx @tailwindcss/upgrade@4inapps/api - Migrates
tailwind.config.mjs→ CSS@themedirective - Renames classes (
shadow-sm→shadow-xs, etc.) - Adds
@tailwindcss/postcss; removespostcss-import/autoprefixer
11. Visual QA each of 32 email templates
- For each
apps/api/src/templates/*.tsx, render in dev (pnpm dev+ react-email preview) - Verify no visual regressions from Tailwind 4 class renames
- Fix any drift case-by-case
12. Bump minor versions
- Bump:
next→ 16.2.6,react→ 19.2.6,zod→ 4.4.3,@sentry/nextjs→ 10.52.0,@tanstack/react-query→ 5.100.9,@biomejs/biome→ 2.4.15,turbo→ 2.9.12,@upstash/ratelimit→ 2.0.8,@playwright/test→ 1.59.1,@supabase/supabase-js→ 2.105.4,tsx→ 4.21.0
13. Align cross-app dep versions
@types/node20 → 22 in apps/web (match runtime)@hookform/resolversapi 4.1 → 5.2 (match web)lucide-reactalign both apps to same minor
14. Remove unused / unmaintained deps
- Remove:
prisma-extension-soft-delete(unmaintained, 15 mo stagnant),node-fetch(Node 22+ has native),swagger-jsdoc+@types/swagger-ui-express(no OpenAPI in v2),axios(zero call sites in our code)
14a. Migrate vi.fn() arrow constructor mocks to function-form for vitest 4
- Vitest 4 changed
vi.fn()mock semantics: arrow factories (vi.fn().mockImplementation(() => ({...}))orvi.fn(() => ({...}))) throwTypeError: () => ({...}) is not a constructorwhen invoked withnew. The legacy v3 behavior allowed arrow factories to construct. - Discovered by item 15's first run: full apps/api test suite reported 69 test files / 394 tests failed, all rooting in this single contract change. Most failing tests mock service classes (
StripeService,EmailService,SmsService,AcuityService,Resend,PostHog) that the production code instantiates withnew. - Fix pattern: replace
vi.fn().mockImplementation(() => ({...}))withvi.fn().mockImplementation(function () { return {...} }); replacevi.fn(() => ({...}))withvi.fn(function () { return {...} }). The function form's explicitreturnof an object satisfies bothf()andnew F()invocation paths (per ECMA-262: an explicit object return from a [[Construct]] call overridesthis). - Scope:
grep -rln "vi\.fn().mockImplementation(() =>\|vi\.fn(() =>" apps packagesreturns 99 files (78 in apps/api, 21 in apps/web). - Validation gate: full
pnpm testpasses (or at least: vitest 4 mock TypeError disappears from the failure tail). Item 15 cannot pass without this.
14b. Migrate remaining arrow-factory constructor-mock variants missed by 14a
- 14a's mechanical grep targeted
vi.fn(() => ({andvi.fn().mockImplementation(() => ({— the literal-constructor forms. Item 15 retry (fullpnpm test) surfaced 33 failing test files / 196 failing tests rooting in two arrow-factory variants 14a did not cover: - (a)
vi.mocked(X).mockImplementation(() => ({...}))(or() => Xwhere X is a pre-built object) — 11 files. Detection: Python regexvi\.mocked\([^)]+\)\s*\.mockImplementation\(\s*(?:\n\s*)?\(\s*\)\s*=>againstapps/{api,web}/**/*.{ts,tsx}(skip node_modules/.next/dist/build/.turbo). Files:apps/api/{tests/api/tours/tours.guest-booking,tests/api/stripe/{stripe.prices-foreign-table,stripe.products},tests/integration/services/payment/StripeWebhookService.pm-price-sync.integration,tests/services/credits/CreditSubscriptionService.unit,tests/unit/jobs/MigrationSmsJobHandler.unit,src/app/api/acuity/appointment-types/route,src/app/api/users/[id]/stripe-balance/route,src/lib/analytics/posthog}.test.ts*plusapps/web/src/app/(app)/account/reservations/[id]/{ReservationAccessCodes,ReservationDirections}.test.tsx. - (b) Shared helpers exporting
const mockImplementation = () => ({...})— 3 files:apps/api/tests/support/consolidated/mocks/{stripe,acuity,email}.mock.ts. Consumed viasetupStripeServiceMock/setupStripeClientMock/setupAcuityServiceMock/setupEmailServiceMockby 10 stripe+reservations+tours+jobs test files (pergrep -rln 'setup(Stripe|StripeClient|Acuity|Email)ServiceMock' apps/api/tests). - Fix pattern (same as 14a): replace
() => ({...})withfunction () { return {...} }. The arrow returns an object literal; the function returns the same literal explicitly. Per ECMA-262 [[Construct]], an explicit object return overridesthis, so the form satisfies bothf()andnew F()invocation paths. - Validation gate: full
pnpm testpasses (or at least: vitest 4 mockTypeError/ "Number of calls: 0" failures disappear from the failing-tail; remaining failures, if any, are non-mock-construction root causes to be filed separately). Item 15 cannot pass without this.
14c. Migrate further arrow-factory constructor-mock variants surfaced by item 15 third retry
- 14b's grep narrowly targeted
vi.mocked(X).mockImplementation(() => ({...}))(literalvi.mocked(...)call wrapper) and the 3 shared-helper exports. Item 15 third retry surfaced 16 failing test files / 94 failing tests rooting in 3 additional arrow-factory variants not covered by 14a or 14b: - (a) Cast-form
(X as any).mockImplementation(() => <expr>)and(X as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => <expr>). Detection: Python regex\([A-Za-z_][\w.]*\s+as\s+[^)]+\)\s*\.mockImplementation\(\s*(?:\n\s*)?\(\s*\)\s*=>againstapps/{api,web}/**/*.{ts,tsx}(skip node_modules/.next/dist/build/.turbo). Confirmed sites:apps/api/tests/api/stripe/{stripe.setup-intent-webhook,stripe.reservation-payment-webhook,stripe.payment-method-attached-webhook}.api.test.ts(many occurrences each),apps/api/tests/api/acuity/acuity.availability.api.test.ts,apps/api/tests/unit/services/user/WaitlistEntryService.unit.test.ts, plus the multi-line variant inapps/api/tests/api/reservations/reservation.acuity-sync.api.test.ts(the arrow opens on a new line:.AcuityService.mockImplementation(\n () =>\n ({...}) as any\n )). - (b) Pre-bound mock variable
MockedService.mockImplementation(() => <expr>)whereMockedServicewas earlier destructured fromvi.mocked(await import(...))or rebound fromvi.mocked(...). Detection: find files withconst { Service } = vi.mocked(...)orconst MockedService = vi.mocked(...)then sweep allService.mockImplementation(() => ({...}))call sites in the same file. Confirmed sites:apps/api/tests/api/stripe/{stripe.customer-portal,stripe.subscription-webhooks,stripe.subscription-renewal,stripe.subscription-lifecycle,referral-code-subscription-lifecycle}.api.test.ts,apps/api/tests/api/insurance/insurance-prices.api.test.ts. - (c) Annotated arrow
mockImplementation((): SomeType => (<expr>) as SomeType)— the explicit TS return-type annotation on the arrow. Detection: Python regex\.mockImplementation\(\s*\n?\s*\(\s*\)\s*:\s*\wagainst the test tree. Confirmed sites:apps/api/tests/api/stripe/stripe.webhook-idempotency.api.test.ts(3 occurrences:configureMocks,configureInvalidSignatureMocks,configureMalformedJSONMocks). - Fix pattern (same as 14a/14b): replace
() => <expr>withfunction () { return <expr> }. Where the<expr>is parenthesized object literal({...}), strip the outer parens; the explicitreturnkeyword no longer needs the grouping. Where the arrow has a return-type annotation(): T => (...) as T, drop the annotation and the cast on the same line (or keep them by rewriting tofunction (): T { return (...) as T }— either is valid; pick consistent with the file's surrounding style). Per ECMA-262 [[Construct]], an explicit object return from a function-form mock overridesthis, satisfying bothf()andnew F()invocation paths. - Validation gate: full
pnpm testpasses (or at least: vitest 4 mockTypeError: ... is not a constructor+Number of calls: 0+expected 500 to be 200/204failure patterns disappear from the failure tail; remaining failures, if any, are non-mock-construction root causes — item 14d covers the Stripe dahlia fixture drift expected to be the next surface). Item 15 cannot pass without this.
14d. Update Stripe fixture shapes in checkout preview tests for dahlia API restructuring
- Item 7's production-code migration (basil → dahlia) was approved on production-code-only review. Item 15 third retry surfaced 3 checkout preview test files where the mock-return fixtures still encode the basil shape; production code now reads dahlia accessors so the mocked-discount/coupon path returns undefined and the preview API returns an error response. Confirmed by inspection — example:
apps/api/tests/api/checkout/waitlist.checkout.api.test.ts:227-235mocksgetCustomerto return{ discount: { coupon: { percent_off: 20, ... } } }(basil), but production code post-item-7 readscustomer.discount?.source?.coupon(dahlia). Same shape drift inmockGetPromotionCode.mockResolvedValue({ id, code, coupon: {...} })fixtures inpreview-discount.api.test.ts:151-161and equivalent inpreview-org-dedicated-room-discount.api.test.ts. - Files:
apps/api/tests/api/checkout/{preview-discount,preview-org-dedicated-room-discount,waitlist.checkout}.api.test.ts - Fix pattern: update fixture object shapes to match dahlia. For
Customer.discountfixtures:{ discount: { coupon: {...} } }→{ discount: { source: { type: 'coupon', coupon: {...} } } }. ForPromotionCodefixtures returned bygetPromotionCode:{ id, code, coupon: {...} }→{ id, code, promotion: { type: 'coupon', coupon: {...} } }. Cross-reference the dahlia type definitions emitted by stripe SDK 22 (apps/api/node_modules/stripe/types/Customers.d.ts,PromotionCodes.d.ts) for the exact discriminator shape. - Validation gate: the 3 preview tests'
isSuccess(result)assertions pass (10+ tests across the 3 files). Independent of 14c — these failures present asAssertionError: expected false to be truefromisSuccess(result), not arrow-factoryTypeErrors; the two root causes don't overlap.
14e. Fix AuthContext impersonation init race surfaced by dep-upgrade chain
- Failure mode:
AuthContext.test.tsx > AuthProvider > uses impersonation profile when impersonatingrace —await waitFor(() => expect(result.current.user).not.toBeNull())satisfies on the synchronous admin-session callback (set first bysupabase.auth.onAuthStateChangemock) beforeinitImpersonation's awaited fetch microtask resolves and overwrites with the target user. Assertinguser.id === 'target-user-123'immediately after returns'admin-user'. - Why it surfaces now: react 19.2.6 / @tanstack/react-query 5.100.9 / vitest 4 changed microtask scheduling in the test harness — previously the impersonation fetch happened to resolve first; now the sync auth-state callback wins. Production code's
impersonatingRef.currentguard atAuthContext.tsx:163still keeps subsequent auth events from clobbering the eventual target-user setUser; this is purely a test-condition race, not a production race. - Fix pattern: replace the non-specific waiter with a target-specific one. Change
await waitFor(() => expect(result.current.user).not.toBeNull())toawait waitFor(() => expect(result.current.user?.id).toBe('target-user-123')). The waiter now blocks until impersonation has overwritten user, eliminating the race. - Files:
apps/web/src/contexts/AuthContext.test.tsx(1 site at line ~409 in theuses impersonation profile when impersonatingtest). Sibling testignores auth events while impersonating(~line 419) has the same setup; verify it passes today and decide whether to harden its waiter pre-emptively (only if a similar race is plausible — current state is green). - Validation:
pnpm --filter @mg/web test src/contexts/AuthContext.test.tsx→ all impersonation tests pass; broaderpnpm --filter @mg/web testunaffected.
14f. Fix TimeSelector pricing-display test brittleness from HeroUI rendering change
- Failure mode:
TimeSelector.test.tsx > pricing display > should display hourly prices when pricing props are provided—getByText((_, element) => element?.tagName === 'SPAN' && element?.textContent === '$21/hr')matches both the outer<span data-slot="endContent">(whose nested textContent equals$21/hr) and the inner<span class="text-sm font-medium text-default-600 tabular-nums">$21/hr</span>. ReturnsTestingLibraryElementError: Found multiple elements. - Why it surfaces now:
@heroui/listbox(and possibly sibling@heroui/*packages) minor bump in item 12 now wrapsListboxItem.endContentchildren in a<span data-slot="endContent">…</span>envelope. Pre-bump the wrapper was either a<div>(different tagName, so the test'stagName === 'SPAN'filter excluded it) or absent. Production rendering is correct —formatPrice(slotPrice)/hris emitted exactly once atTimeSelector.tsx:138-142; the outer span is the slot envelope rendered by HeroUI. - Fix pattern: tighten the matcher to a single, specific element. Either (a)
screen.getByText((_, el) => el?.tagName === 'SPAN' && el?.textContent === '$21/hr' && el.className.includes('tabular-nums'))— keys on the price span's distinguishing class; or (b)expect(screen.getAllByText((_, el) => el?.tagName === 'SPAN' && el?.textContent === '$21/hr')).toHaveLength(2)— accepts the wrap as part of the expected DOM and verifies count. (a) is preferred for readability and resilience to further HeroUI slot changes. - Files:
apps/web/src/components/molecules/scheduling/TimeSelector.test.tsx(1 failure site near line 240). Verify no neighboring subtests use the same matcher pattern — if so, apply the same tightening to keep the test file internally consistent. - Validation:
pnpm --filter @mg/web test src/components/molecules/scheduling/TimeSelector.test.tsx→ green across all subtests.
15. Verify all dep upgrades land green
pnpm installcleanpnpm --filter @mg/api typecheck:quick && pnpm --filter @mg/web typecheck:quickpass- Full v1 test suite passes (
pnpm test) - No runtime regression in dev
16. Create framework directory structure + module exports
- Create
apps/api/src/lib/http/with sub-dirs:output/,middleware/,adapters/ - Create
apps/api/src/lib/auth/predicates/for predicate sub-modules - Create
apps/api/src/lib/errors/for typed error registries - Create
apps/api/src/lib/observability/for logger/tracing/sentry - Add
lib/http/index.tsandlib/errors/index.tsre-exporting public surface - Spec coverage: File layout
17. Define core types
- File:
lib/http/types.ts - Types:
RouteSpec<TInput, TOutput>,RouteContext,OutputKind,AuthPredicate,InputSchema - Spec coverage: The defineRoute API, Handler context
18. Implement RouteError.notFound/unauthenticated/forbidden
- File:
lib/http/route-error.ts - Static factory methods returning
RouteErrorinstances with the appropriate type URI + status - Spec coverage: Error model
19. Implement RouteError.validation/conflict/unprocessable/paymentRequired
- Extend
lib/http/route-error.ts validationaccepts Zod issues directly;conflictaccepts optional issues array
20. Implement RouteError.rateLimited/maintenance/externalUnavailable/internal
- Extend
lib/http/route-error.ts rateLimitedrequiresretryAfter;internalacceptscause: Errorfor Sentry chain
21. Implement RouteError.businessRule + toProblemDetails()
- Accept
BusinessRuleKind(typed); produce type URI/errors/business-rule/<kind> toProblemDetails()serializes to RFC 9457 shape withContent-Type: application/problem+json
22. Create BusinessRuleKind typed registry
- File:
lib/errors/business-rules.ts - Starter kinds:
reservation-already-cancelled,reservation-overlaps-existing,lockout-active,insufficient-credit-balance,duplicate-checkout-invitation - Each entry maps to
{ title: string }
23. Create ExternalService typed registry
- File:
lib/errors/external-services.ts - Union:
'stripe' | 'twilio' | 'supabase' | 'slack' | 'acuity' | 'upstash'
24. Create AuditSource typed registry
- File:
lib/errors/audit-sources.ts - Starter:
'api' | 'cron:<job-name>' | 'webhook:<provider>' | 'public:<route>' - Template-literal types where possible
25. Implement defineRoute shell with discriminated-union signature
- File:
lib/http/define-route.ts - Function accepts the full options object; TypeScript prevents mixing output kinds with wrong schemas at compile time
- Returns
RouteSpec(typed value, not handler yet — adapter wiring comes later) - Spec coverage: The defineRoute API
26. ok(schema) output kind
- File:
lib/http/output/ok.ts - Returns
{ kind: 'ok', schema }; framework's adapter serializes handler return value as 200 + JSON - Spec coverage: Output kinds
27. created(schema) with Location header
- File:
lib/http/output/created.ts - Adapter sets status 201 +
Locationheader from handler return value'sid
28. list(itemSchema, opts) with cursor + withTotal
- File:
lib/http/output/list.ts - Adapter wraps handler's
{ items, nextCursor }into{ data, pagination: { nextCursor, hasMore, total? } } - Spec coverage: Pagination
29. empty() (204)
- File:
lib/http/output/empty.ts - Adapter returns 204 with no body
30. redirect({ to, sameOriginOnly, status? }) with allowlist
- File:
lib/http/output/redirect.ts - Resolves
tovia callback; validates againstALLOWED_ORIGINSenv whensameOriginOnly: true - Throws
RouteError.forbiddenon invalid origin
31. file({ contentType, filename })
- File:
lib/http/output/file.ts - Adapter returns body bytes/string with
Content-Type+Content-Disposition: attachment; filename=...
32. stream({ contentType })
- File:
lib/http/output/stream.ts - Adapter passes
ReadableStreamthrough withContent-Type; no Cache-Control override
33. twiml()
- File:
lib/http/output/twiml.ts - Adapter returns body with
Content-Type: text/xml
34. requires.public() + requires.user.authenticated() (incl. impersonation)
- File:
lib/auth/predicates/user.ts authenticated()honorsx-impersonateheader: validatesImpersonationSession, populatesauth.userId(target) +auth.impersonatorId(admin)- Spec coverage: Auth predicates
35. requires.user.admin/staff/staffAt/role
- Extend
lib/auth/predicates/user.ts - Wrap existing role-check logic (
apps/api/src/utils/auth/role-check.ts) — predicates own this now;role-check.tsdeletes later
36. requires.user.owns(resource, idFn, opts?)
- Loads resource by id from
idFn(ctx), checksuserId === auth.userId - Default
deletedAt: null; opt-in via{ includeDeleted: true } - Populates
auth.entities[resource]for handler reuse - Spec coverage: Auth predicates + URL conventions (hard-delete reservation)
37. requires.user.belongsToOrgOf(resource, idFn)
- For tenant-scoped routes (lockouts via location → organization)
- Loads resource, walks to organization, checks user is org member
38. requires.cron()
- File:
lib/auth/predicates/cron.ts - Checks
Authorization: Bearer ${CRON_SECRET}env
39. requires.webhook.stripe()
- File:
lib/auth/predicates/webhook.ts - Wraps
stripe.webhooks.constructEvent(rawBody, signature, secret) - Populates
auth.webhook.eventtyped asStripe.Event
40. requires.webhook.twilio()
- Extend
lib/auth/predicates/webhook.ts - Wraps Twilio SDK's
validateRequest(authToken, signature, url, params)
41. requires.webhook.slack()
- Extend
lib/auth/predicates/webhook.ts - Wraps existing
verifySlackSignature()helper atapps/api/src/lib/slack.ts
42. requires.webhook.supabase()
- Extend
lib/auth/predicates/webhook.ts - Checks
x-webhook-signatureheader againstSUPABASE_WEBHOOK_SECRETenv
43. Predicate composers: all, any, not
- File:
lib/auth/predicates/composers.ts - Each accepts predicates and combines results; entity caches merge
44. Predicate entity-cache infrastructure
- File:
lib/auth/predicates/entity-cache.ts - Per-request
Map<string, unknown>keyed by resource type; predicates write, handlers read viactx.auth.entities
45. HMAC-signed cursor encode/decode
- File:
lib/http/cursor.ts encodeCursor({ id, createdAt })→ base64 JSON + HMAC-SHA256 signature;decodeCursor(s)validates + parses- Env:
CURSOR_SIGNING_SECRET - Spec coverage: Pagination
46. IdempotencyKey Prisma model + migration
- File:
apps/api/prisma/schema.prisma(append model) - Model fields per spec; index on
expiresAt - Generate + apply migration
- Spec coverage: Idempotency
47. Idempotency middleware
- File:
lib/http/middleware/idempotency.ts - Reads typed
keyFromconfig; checksWebhookEvent(webhook) orIdempotencyKey(header/body) table - On replay: returns cached response without invoking handler
- On reuse-with-different-body: throws
RouteError.conflict
48. Rate limiter
- File:
lib/http/middleware/rate-limit.ts - Global default + per-route override; uses
@upstash/ratelimitdirectly - Returns
RouteError.rateLimited({ retryAfter })withRetry-Afterheader
49. CSRF: SameSite + double-submit token
- File:
lib/http/middleware/csrf.ts - Sets
mg-csrfcookie (32-byte random, non-HttpOnly); validates header match on state-changing methods - Mismatch →
RouteError.forbidden({ detail: 'CSRF check failed' }) - Spec coverage: CSRF protection
50. CORS handler
- File:
lib/http/middleware/cors.ts - Attaches headers based on route's auth predicate (webhook/cron: none; public:
*; authenticated: origin-matched)
51. OPTIONS preflight handler
- Centralized in Next adapter (item 59)
- Matches path against registry; returns 204 with
Allow-*headers - Disallowed origin →
RouteError.forbidden
52. Cache-Control: no-store default + per-route opt-out
- Output kinds accept optional
cache?: string; framework setsCache-Control: no-storeotherwise
53. Zod issue → RFC 9457 validation-error mapping
- File:
lib/http/middleware/validation-errors.ts - Converts
z.core.$ZodIssue[]toissuesarray on the problem document - Preserves Zod's native shape (
path,code,message, issue-specific fields)
54. Audit context wiring
- File:
lib/http/middleware/audit.ts - Reads existing
audit-context.ts(AsyncLocalStorage+ 4 GUCs atapps/api/src/lib/database/audit-context.ts) - Populates from
ctx.auth.userId,ctx.auth.impersonatorId, route'saudit.source,ctx.requestId - Spec coverage: Audit context
55. ctx.logger writing JSON to stdout
- File:
lib/observability/logger.ts - Methods:
debug/info/warn/error(msg, extra?) - Emits
{ ts, level, msg, requestId, route, userId, source, ...extra }JSON
56. Request ID generation + x-req-id pass-through
- File:
lib/observability/request-id.ts - Reads incoming
x-req-id; generates viacrypto.randomUUID()if absent - Attaches to response header + log fields + trace span
57. ctx.trace wrapping Sentry span API
- File:
lib/observability/tracing.ts - Methods:
startSpan(name, attrs); delegates to@sentry/nextjsspan API
58. Framework error catch → Sentry / INFO
- File:
lib/http/middleware/error-handler.ts - 5xx
RouteError.internal(and unknown thrown) →Sentry.captureExceptionwith full context - 4xx
RouteError.*→ INFO log only, no Sentry
59. Next App Router adapter
- File:
lib/http/adapters/next.ts - Compiles
RouteSpecto Next route handler function; wires preflight, CORS, rate limit, CSRF, audit, error catch
60. Test adapter (in-process)
- File:
lib/http/adapters/test.ts - Compiles
RouteSpecto a function callable from tests with no HTTP server; accepts injecteddb,auth,input
61. Route shim codegen
- Script:
apps/api/scripts/codegen-route-shims.ts - Crawls
apps/api/src/routes/; emitsapps/api/src/app/api/**/route.tsshims that re-export from the route declaration
62. invoke(route, args) test helper
- File:
apps/api/tests/_helpers/invoke.ts - Wraps test adapter; returns
{ status, body }; auto-validates body against route's declared output schema
63. invokeExpectingError(route, args) test helper
- Extend
apps/api/tests/_helpers/invoke.ts - Returns
{ status, problem }; asserts response isapplication/problem+json
64. Test auth context factories
- File:
apps/api/tests/_helpers/auth.ts - Factories:
testUser({ id?, email? }),testStaff(),testAdmin(),testImpersonator({ admin, target }) - Each returns a fully-constructed
ResolvedAuthContext
65. Generate typed frontend client
- Script:
packages/api-client/scripts/codegen.ts - Crawls route registry; emits
packages/api-client/src/generated.tswith typed call functions
66. Generate React Query hooks
- Extend codegen; emits
packages/api-client/src/hooks.ts - Each route gets
useApi/useApiList/useApiMutationwrapper as appropriate
67. Client-side ApiError + retry
- File:
packages/api-client/src/api-error.ts - Class wraps RFC 9457 problem;
is(typeShortname)helper; exposesissuesfor validation errors - Fetch wrapper retries 429/503 with
Retry-Afterexponential backoff
68. no-raw-response ripgrep pre-commit hook
- File:
.git/hooks/pre-commit(or husky equivalent) - Greps for
new Response(|NextResponse\.|Response\.json|Response\.redirectoutsideapps/api/src/lib/http/ - Blocks commit on match
69. no-route-export-bypass ripgrep pre-commit hook
- Greps
apps/api/src/routes/**/*.tsforexport const (GET|POST|PUT|PATCH|DELETE)— routes must exportdefineRoutecalls only
70. no-implicit-hard-delete ripgrep pre-commit hook
- Greps for
db\..*\.delete\(|\.deleteMany\(outside test fixtures, draft cleanup, admin tools allowlist
71. no-side-effects-in-patch ripgrep pre-commit hook
- For
apps/api/src/routes/**/*.tswith PATCH method: flag imports of side-effecting services (Stripe/Email/SMS/etc.)
72. Rename AppError → DomainError across services
- Files: all 102 service files using
AppError - Rename class + import paths; add
DomainError.kindtyped enum - Route handlers' default catch maps
DomainError.kind→RouteError
73. Adapt UserService signatures
- File:
apps/api/src/services/user/UserService.ts - Drop
req: Requestparameters; takeauditContext: AuditContext+tx: PrismaTransactionClient - Methods used by Phase 1 routes:
findById,list,approve,ban,update
74. Adapt StripeService for webhook + cron usage
- File:
apps/api/src/services/payment/StripeService.ts - Drop
req; takeauditContext+tx; exposeprocessEvent(event, db)+syncAll(db)cleanly
75. Adapt FinanceService for CSV export usage
- File:
apps/api/src/services/finance/* buildCsv(filters, tx)signature; returns string
76. Adapt ReservationService for getReservation
- File:
apps/api/src/services/scheduling/ReservationService.ts findById(id, tx, opts?)returning the entity; works alongside owner predicate's pre-loaded cache
77. Adapt ContactService for contactForm
- File:
apps/api/src/services/contact/ContactService.ts submitInquiry({ name, email, phone?, message, locationId? }, tx)with side effects (email + Slack notification)
78. permissions/users.ts
- File:
apps/api/src/permissions/users.ts - Functions actually shipped:
canApproveUsers,canViewUser(idFn) canBanUsers/canEditUserwere in the original plan but removed during pass-1 simplify since no v2 Phase 1 route used them — re-add when a future route needs the permission.- Compose predicates from Block D
79. permissions/reservations.ts
- File:
apps/api/src/permissions/reservations.ts - Functions actually shipped:
canViewReservation(idFn)= owns OR staff-at-location (location resolved via cached hydrated reservation) canCancelReservation/canCheckInReservationwere in the original plan but removed during pass-1 simplify since no v2 Phase 1 route used them — re-add when actions/cancel and actions/check-in are migrated in a later phase.
80. permissions/locations.ts
- File deleted during pass-1 simplify. The original plan listed
canEditLocation/canViewLocationFinancebut no v2 Phase 1 route used them. Re-add the file when admin location-edit / finance views migrate.
81. getUser — GET /users/:id
- File:
apps/api/src/routes/users/byId.ts defineRoutewith owner-or-staff permission,ok(userSchema)output- Test:
apps/api/tests/api/users/getUser.test.ts - Frontend: regenerate
api.users.byIdclient + hook
82. listUsers — GET /users
- File:
apps/api/src/routes/users/list.ts - Cursor pagination,
withTotal: true, staff-only - Test + frontend regen
83. approveUser — POST /users/:id/actions/approve
- File:
apps/api/src/routes/users/actions/approve.ts - Permission predicate; calls
UserService.approve; idempotent viaauditContext - Test + frontend regen
84. stripeWebhook (a) — route declaration + signature predicate
- File:
apps/api/src/routes/webhooks/stripe.ts defineRoutewithrequires.webhook.stripe(),ok({ received: true }),idempotency: { keyFrom: 'webhook:stripe-event-id' }- Empty handler placeholder (dispatcher in item 85)
85. stripeWebhook (b) — dispatcher adapted to ctx.auth.webhook.event
- Port v1's 641-line dispatcher into the new handler body
- Read event from
ctx.auth.webhook.event; dispatch on event type via service layer - Replace
withAuthTransactioncalls withctx.db
86. stripeWebhook (c) — tests
- File:
apps/api/tests/api/webhooks/stripe.test.ts - Cover: happy path (charge.succeeded), replay (idempotency dedupe), invalid signature (predicate rejects)
87. syncStripeCron — POST /cron/sync-stripe-and-refresh
- File:
apps/api/src/routes/cron/sync-stripe-and-refresh.ts requires.cron(), preview skip, cron result schema- Test:
cron/sync-stripe-and-refresh.test.ts
88. exportFinance — GET /finance/exports
- File:
apps/api/src/routes/finance/exports/list.ts(or similar) file({ contentType: 'text/csv', filename: 'finance-export.csv' })output- Test + frontend regen
89. oauthCallback — GET /public/auth/oauth-callback
- File:
apps/web/src/routes/public/auth/oauth-callback.ts(or apps/api per spec — verify during impl) redirect({ to: ({ input }) => input.query.next || '/dashboard', sameOriginOnly: true })- Test: covers
nextallowlist enforcement
90. docsAsk — POST /docs/ask
- File:
apps/api/src/routes/docs/ask.ts stream({ contentType: 'text/event-stream' })output- Staff-only; handler returns
ReadableStream - Test: covers SSE chunks
91. getReservation — GET /reservations/:id
- File:
apps/api/src/routes/reservations/byId.ts - Owner-scoped predicate (
requires.user.owns('reservation', idFn)); handler reads fromctx.auth.entities.reservationcache - Test + frontend regen
92. contactForm — POST /public/contact
- File:
apps/api/src/routes/public/contact.ts requires.public(),csrf: 'none'(with comment), Turnstile token validation in handler- Side effects: email + Slack notification via
ContactService - Test + frontend regen
93. Phase 1 validation gate
- Run all 10 representative routes end-to-end against dev DB
- Lint rules CI-enforced; all 4 ripgrep hooks active
pnpm typecheck:quickpasses in both apps- Phase 1 test suite green; existing v1 tests for non-rewritten routes still green
- Frontend client + hooks generated successfully; smoke-test 10 hook usages from a UI page
94. Spec follow-up
- Update
spec.mdif Phase 1 surfaced design gaps (e.g., a predicate that doesn't compose as designed) - Update
audit.mdstatus header pointing to live framework - Mark this plan's Status to
doneonce all items above are[x]
Order of merge
Phase 1 ships as a single feature branch off main. v1 stays frozen during Phase 1 work (bug fixes only). Branch merges only when Block N items are green. v2 routes don't replace v1 routes yet — that's Phase 2's job. Phase 1 land = framework + 10 reference routes coexist with v1 routes for the rest of the codebase.
Rollback
If Phase 1 lands but proves wrong-shaped, revert the merge commit. v1 routes are untouched and continue serving traffic. Service signature adaptations (Block K) are mechanical and reversible. ESM flip + dep upgrades are reversible via git. IdempotencyKey table addition is additive and safe to leave on rollback.
Out of scope
- Phase 2 bulk migration (~300 remaining routes) — tracked separately when Phase 1 lands
- Phase 3 full frontend rewrite (UI components beyond the 10 hooks regenerated for Phase 1's routes)
- Phase 4 edge layer migration (proxy.ts/cors.ts not yet routed through framework)
- Phase 5 cutover (Stripe/Supabase URL changes deferred until full v2 lands)
- Prisma 7 upgrade (blocked on upstream issue #28627)