Skip to content

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 __dirname with fileURLToPath shim in ~10 scripts
  • [x] 3. Replace dynamic require('crypto') with static import in StripeService.ts:765
  • [x] 4. Upgrade pnpm 8 → 11; migrate config from package.json.pnpm to pnpm-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/node 22 in apps/web; align @hookform/resolvers 5.2; align lucide-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 throws TypeError: arrow is not a constructor on new when 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 in apps/api/tests/support/consolidated/mocks/{stripe,acuity,email}.mock.ts exporting const 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 6 apps/api/tests/api/stripe/stripe.{setup-intent,reservation-payment,payment-method-attached}-webhook.api.test.ts, plus acuity.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 variable MockedStripeService.mockImplementation(() => <expr>) where MockedStripeService was destructured/rebound from vi.mocked(...) — appears in 5+ files including stripe.{customer-portal,subscription-webhooks,subscription-renewal,subscription-lifecycle,referral-code-subscription-lifecycle}.api.test.ts and insurance/insurance-prices.api.test.ts; (c) annotated arrow mockImplementation((): 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.ts mock getCustomer/getPromotionCode to return basil shape discount.coupon / promotionCode.coupon directly, but item 7 migrated production code to dahlia shape discount.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 — expected result.current.user?.id 'target-user-123', got 'admin-user'. Race: AuthProvider's useEffect fires init() (calls async initImpersonation which awaits fetch(profile)); then synchronously subscribes to supabase.auth.onAuthStateChange whose mocked callback fires sync with admin session and sets user=admin (impersonatingRef still false). Test's await waitFor(() => result.current.user).not.toBeNull() satisfies on user=admin and asserts before initImpersonation's fetch microtask resolves to overwrite with target. Fix: test-side — replace await waitFor(() => expect(result.current.user).not.toBeNull()) with await 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 the impersonatingRef.current guard 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 providedTestingLibraryElementError: Found multiple elements with the text: ... element?.tagName === "SPAN" && element?.textContent === "$21/hr". HeroUI now wraps ListboxItem.endContent in <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 the endContent prop; the outer wrap is HeroUI's slot envelope. Fix: test-side — make the matcher specific to the inner price span (assert by class text-default-600.tabular-nums or use getAllByText + 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, AuthPredicate types
  • [x] 18. Implement RouteError factories: notFound, unauthenticated, forbidden
  • [x] 19. Implement RouteError factories: validation, conflict, unprocessable, paymentRequired
  • [x] 20. Implement RouteError factories: rateLimited, maintenance, externalUnavailable, internal
  • [x] 21. Implement RouteError.businessRule + toProblemDetails() RFC 9457 serializer
  • [x] 22. Create BusinessRuleKind typed registry
  • [x] 23. Create ExternalService typed registry
  • [x] 24. Create AuditSource typed registry
  • [x] 25. Implement defineRoute shell with discriminated-union type signature

Block C — Output kinds

  • [x] 26. ok(schema) output kind
  • [x] 27. created(schema) with Location header
  • [x] 28. list(itemSchema, opts) with cursor + optional withTotal
  • [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-impersonate header handling)
  • [x] 35. requires.user.admin(), staff(), staffAt(), role()
  • [x] 36. requires.user.owns(resource, idFn, opts?) with deletedAt: null default
  • [x] 37. requires.user.belongsToOrgOf()
  • [x] 38. requires.cron()
  • [x] 39. requires.webhook.stripe() wrapping stripe.webhooks.constructEvent
  • [x] 40. requires.webhook.twilio() wrapping validateRequest
  • [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. IdempotencyKey Prisma model + migration
  • [x] 47. Idempotency middleware reading typed keyFrom enum
  • [x] 48. Rate limiter (Upstash) with global default + per-route override
  • [x] 49. CSRF: SameSite + double-submit token (mg-csrf cookie + X-CSRF-Token header)
  • [x] 50. CORS handler with per-route auth-aware origin matching
  • [x] 51. OPTIONS preflight handler (centralized)
  • [x] 52. Cache-Control: no-store default + per-route opt-out via output kind cache option
  • [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.logger writing JSON to stdout with correlation fields
  • [x] 56. Request ID generation + x-req-id header pass-through
  • [x] 57. ctx.trace wrapping 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, testImpersonator auth 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 with Retry-After

Block J — Lint rules

  • [x] 68. no-raw-response ripgrep pre-commit hook
  • [x] 69. no-route-export-bypass ripgrep pre-commit hook
  • [x] 70. no-implicit-hard-delete ripgrep pre-commit hook
  • [x] 71. no-side-effects-in-patch ripgrep pre-commit hook

Block K — Service signature adaptations (for Phase 1 routes)

  • [x] 72. Rename AppErrorDomainError across services + add DomainError.kind registry
  • [x] 73. Adapt UserService signatures (drop req, take auditContext + tx)
  • [x] 74. Adapt StripeService for webhook + cron usage
  • [x] 75. Adapt FinanceService for CSV export usage
  • [x] 76. Adapt ReservationService (for getReservation)
  • [x] 77. Adapt ContactService (for contactForm)

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. getUserGET /users/:id (entity + owner-or-staff)
  • [x] 82. listUsersGET /users (cursor + withTotal, staff-only)
  • [x] 83. approveUserPOST /users/:id/actions/approve
  • [x] 84. stripeWebhook (a) — route declaration + signature predicate integration
  • [x] 85. stripeWebhook (b) — dispatcher adapted to ctx.auth.webhook.event + idempotency wiring
  • [x] 86. stripeWebhook (c) — tests: happy path, replay, invalid signature
  • [x] 87. syncStripeCronPOST /cron/sync-stripe-and-refresh (cron auth, preview skip)
  • [x] 88. exportFinanceGET /finance/exports (file output, staff-only)
  • [x] 89. oauthCallbackGET /public/auth/oauth-callback (redirect, sameOriginOnly, public)
  • [x] 90. docsAskPOST /docs/ask (streaming output, staff-only)
  • [x] 91. getReservationGET /reservations/:id (owner-scoped predicate, loads entity)
  • [x] 92. contactFormPOST /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

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}.ts and 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) with import crypto from 'node:crypto' at top

4. Upgrade pnpm 8 → 11 + migrate workspace config

  • Bump pnpm version in package.json engines + lockfile regeneration
  • Move pnpm field from package.json to pnpm-workspace.yaml (silent-ignore in v11)
  • Rename npm_config_* env vars to pnpm_config_*
  • Migrate onlyBuiltDependencies etc. to new allowBuilds field

5. Bump Node engines 22 → 24 LTS

  • Files: root package.json engines field, Vercel project settings
  • No code changes; pure runtime bump

6. Upgrade TypeScript 5.7 → 6.0

  • Bump typescript dep; fix any strict-default surface issues (we already have strict: true)
  • Confirm types default-empty change doesn't break @types/node pickup

7. Upgrade Stripe SDK 18 → 22

  • Bump stripe dep; verify no decimal_string/StripeContext/Stripe.errors.StripeError usage (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 twilio dep; 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().getMockName usage for new semantics

10. Run Tailwind 3 → 4 auto-migration tool on apps/api

  • Run npx @tailwindcss/upgrade@4 in apps/api
  • Migrates tailwind.config.mjs → CSS @theme directive
  • Renames classes (shadow-smshadow-xs, etc.)
  • Adds @tailwindcss/postcss; removes postcss-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/node 20 → 22 in apps/web (match runtime)
  • @hookform/resolvers api 4.1 → 5.2 (match web)
  • lucide-react align 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(() => ({...})) or vi.fn(() => ({...}))) throw TypeError: () => ({...}) is not a constructor when invoked with new. 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 with new.
  • Fix pattern: replace vi.fn().mockImplementation(() => ({...})) with vi.fn().mockImplementation(function () { return {...} }); replace vi.fn(() => ({...})) with vi.fn(function () { return {...} }). The function form's explicit return of an object satisfies both f() and new F() invocation paths (per ECMA-262: an explicit object return from a [[Construct]] call overrides this).
  • Scope: grep -rln "vi\.fn().mockImplementation(() =>\|vi\.fn(() =>" apps packages returns 99 files (78 in apps/api, 21 in apps/web).
  • Validation gate: full pnpm test passes (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(() => ({ and vi.fn().mockImplementation(() => ({ — the literal-constructor forms. Item 15 retry (full pnpm 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 () => X where X is a pre-built object) — 11 files. Detection: Python regex vi\.mocked\([^)]+\)\s*\.mockImplementation\(\s*(?:\n\s*)?\(\s*\)\s*=> against apps/{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* plus apps/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 via setupStripeServiceMock/setupStripeClientMock/setupAcuityServiceMock/setupEmailServiceMock by 10 stripe+reservations+tours+jobs test files (per grep -rln 'setup(Stripe|StripeClient|Acuity|Email)ServiceMock' apps/api/tests).
  • Fix pattern (same as 14a): replace () => ({...}) with function () { return {...} }. The arrow returns an object literal; the function returns the same literal explicitly. Per ECMA-262 [[Construct]], an explicit object return overrides this, so the form satisfies both f() and new F() invocation paths.
  • Validation gate: full pnpm test passes (or at least: vitest 4 mock TypeError / "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(() => ({...})) (literal vi.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*=> against apps/{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 in apps/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>) where MockedService was earlier destructured from vi.mocked(await import(...)) or rebound from vi.mocked(...). Detection: find files with const { Service } = vi.mocked(...) or const MockedService = vi.mocked(...) then sweep all Service.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*\w against 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> with function () { return <expr> }. Where the <expr> is parenthesized object literal ({...}), strip the outer parens; the explicit return keyword 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 to function (): 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 overrides this, satisfying both f() and new F() invocation paths.
  • Validation gate: full pnpm test passes (or at least: vitest 4 mock TypeError: ... is not a constructor + Number of calls: 0 + expected 500 to be 200/204 failure 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-235 mocks getCustomer to return { discount: { coupon: { percent_off: 20, ... } } } (basil), but production code post-item-7 reads customer.discount?.source?.coupon (dahlia). Same shape drift in mockGetPromotionCode.mockResolvedValue({ id, code, coupon: {...} }) fixtures in preview-discount.api.test.ts:151-161 and equivalent in preview-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.discount fixtures: { discount: { coupon: {...} } }{ discount: { source: { type: 'coupon', coupon: {...} } } }. For PromotionCode fixtures returned by getPromotionCode: { 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 as AssertionError: expected false to be true from isSuccess(result), not arrow-factory TypeErrors; 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 impersonating race — await waitFor(() => expect(result.current.user).not.toBeNull()) satisfies on the synchronous admin-session callback (set first by supabase.auth.onAuthStateChange mock) before initImpersonation's awaited fetch microtask resolves and overwrites with the target user. Asserting user.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.current guard at AuthContext.tsx:163 still 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()) to await 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 the uses impersonation profile when impersonating test). Sibling test ignores 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; broader pnpm --filter @mg/web test unaffected.

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 providedgetByText((_, 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>. Returns TestingLibraryElementError: Found multiple elements.
  • Why it surfaces now: @heroui/listbox (and possibly sibling @heroui/* packages) minor bump in item 12 now wraps ListboxItem.endContent children in a <span data-slot="endContent">…</span> envelope. Pre-bump the wrapper was either a <div> (different tagName, so the test's tagName === 'SPAN' filter excluded it) or absent. Production rendering is correct — formatPrice(slotPrice)/hr is emitted exactly once at TimeSelector.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 install clean
  • pnpm --filter @mg/api typecheck:quick && pnpm --filter @mg/web typecheck:quick pass
  • 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.ts and lib/errors/index.ts re-exporting public surface
  • Spec coverage: File layout

17. Define core types

18. Implement RouteError.notFound/unauthenticated/forbidden

  • File: lib/http/route-error.ts
  • Static factory methods returning RouteError instances with the appropriate type URI + status
  • Spec coverage: Error model

19. Implement RouteError.validation/conflict/unprocessable/paymentRequired

  • Extend lib/http/route-error.ts
  • validation accepts Zod issues directly; conflict accepts optional issues array

20. Implement RouteError.rateLimited/maintenance/externalUnavailable/internal

  • Extend lib/http/route-error.ts
  • rateLimited requires retryAfter; internal accepts cause: Error for Sentry chain

21. Implement RouteError.businessRule + toProblemDetails()

  • Accept BusinessRuleKind (typed); produce type URI /errors/business-rule/<kind>
  • toProblemDetails() serializes to RFC 9457 shape with Content-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 + Location header from handler return value's id

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 to via callback; validates against ALLOWED_ORIGINS env when sameOriginOnly: true
  • Throws RouteError.forbidden on 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 ReadableStream through with Content-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() honors x-impersonate header: validates ImpersonationSession, populates auth.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.ts deletes later

36. requires.user.owns(resource, idFn, opts?)

  • Loads resource by id from idFn(ctx), checks userId === 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.event typed as Stripe.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 at apps/api/src/lib/slack.ts

42. requires.webhook.supabase()

  • Extend lib/auth/predicates/webhook.ts
  • Checks x-webhook-signature header against SUPABASE_WEBHOOK_SECRET env

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 via ctx.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 keyFrom config; checks WebhookEvent (webhook) or IdempotencyKey (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/ratelimit directly
  • Returns RouteError.rateLimited({ retryAfter }) with Retry-After header

49. CSRF: SameSite + double-submit token

  • File: lib/http/middleware/csrf.ts
  • Sets mg-csrf cookie (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 sets Cache-Control: no-store otherwise

53. Zod issue → RFC 9457 validation-error mapping

  • File: lib/http/middleware/validation-errors.ts
  • Converts z.core.$ZodIssue[] to issues array 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 at apps/api/src/lib/database/audit-context.ts)
  • Populates from ctx.auth.userId, ctx.auth.impersonatorId, route's audit.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 via crypto.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/nextjs span API

58. Framework error catch → Sentry / INFO

  • File: lib/http/middleware/error-handler.ts
  • 5xx RouteError.internal (and unknown thrown) → Sentry.captureException with full context
  • 4xx RouteError.* → INFO log only, no Sentry

59. Next App Router adapter

  • File: lib/http/adapters/next.ts
  • Compiles RouteSpec to 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 RouteSpec to a function callable from tests with no HTTP server; accepts injected db, auth, input

61. Route shim codegen

  • Script: apps/api/scripts/codegen-route-shims.ts
  • Crawls apps/api/src/routes/; emits apps/api/src/app/api/**/route.ts shims 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 is application/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.ts with typed call functions

66. Generate React Query hooks

  • Extend codegen; emits packages/api-client/src/hooks.ts
  • Each route gets useApi/useApiList/useApiMutation wrapper as appropriate

67. Client-side ApiError + retry

  • File: packages/api-client/src/api-error.ts
  • Class wraps RFC 9457 problem; is(typeShortname) helper; exposes issues for validation errors
  • Fetch wrapper retries 429/503 with Retry-After exponential 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\.redirect outside apps/api/src/lib/http/
  • Blocks commit on match

69. no-route-export-bypass ripgrep pre-commit hook

  • Greps apps/api/src/routes/**/*.ts for export const (GET|POST|PUT|PATCH|DELETE) — routes must export defineRoute calls 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/**/*.ts with PATCH method: flag imports of side-effecting services (Stripe/Email/SMS/etc.)

72. Rename AppErrorDomainError across services

  • Files: all 102 service files using AppError
  • Rename class + import paths; add DomainError.kind typed enum
  • Route handlers' default catch maps DomainError.kindRouteError

73. Adapt UserService signatures

  • File: apps/api/src/services/user/UserService.ts
  • Drop req: Request parameters; take auditContext: 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; take auditContext + tx; expose processEvent(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/canEditUser were 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/canCheckInReservation were 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/canViewLocationFinance but no v2 Phase 1 route used them. Re-add the file when admin location-edit / finance views migrate.

81. getUserGET /users/:id

  • File: apps/api/src/routes/users/byId.ts
  • defineRoute with owner-or-staff permission, ok(userSchema) output
  • Test: apps/api/tests/api/users/getUser.test.ts
  • Frontend: regenerate api.users.byId client + hook

82. listUsersGET /users

  • File: apps/api/src/routes/users/list.ts
  • Cursor pagination, withTotal: true, staff-only
  • Test + frontend regen

83. approveUserPOST /users/:id/actions/approve

  • File: apps/api/src/routes/users/actions/approve.ts
  • Permission predicate; calls UserService.approve; idempotent via auditContext
  • Test + frontend regen

84. stripeWebhook (a) — route declaration + signature predicate

  • File: apps/api/src/routes/webhooks/stripe.ts
  • defineRoute with requires.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 withAuthTransaction calls with ctx.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. syncStripeCronPOST /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. exportFinanceGET /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. oauthCallbackGET /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 next allowlist enforcement

90. docsAskPOST /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. getReservationGET /reservations/:id

  • File: apps/api/src/routes/reservations/byId.ts
  • Owner-scoped predicate (requires.user.owns('reservation', idFn)); handler reads from ctx.auth.entities.reservation cache
  • Test + frontend regen

92. contactFormPOST /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:quick passes 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.md if Phase 1 surfaced design gaps (e.g., a predicate that doesn't compose as designed)
  • Update audit.md status header pointing to live framework
  • Mark this plan's Status to done once 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)