# Supabase/Prisma/Docker Removal — Full Convex Cutover

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Remove all Supabase, Prisma, and Docker dev infrastructure, making Convex + Clerk + Upstash the sole stack.

**Architecture:** Convex is the only database. Clerk handles auth. Upstash Redis powers BullMQ only. Local dev runs `next dev` + `convex dev` with no containers.

**Tech Stack:** Convex (DB + storage + scheduled functions), Clerk (auth), Upstash Redis (BullMQ only), Next.js 14, TypeScript

**Design doc:** `docs/plans/2026-03-08-supabase-prisma-docker-removal-design.md`

---

## Phase 1: Convex Gap Fixes

### Task 1: Nurture Domain — Create Convex Functions

The nurture domain has schema (`convex/schema/nurture.ts`, 102 lines, 4 tables) but zero query/mutation functions.

**Files:**

- Create: `convex/nurture/queries.ts`
- Create: `convex/nurture/mutations.ts`
- Reference: `convex/schema/nurture.ts` (4 tables: `email_nurture_sequences`, `email_nurture_sequence_steps`, `user_nurture_sequence_states`, `user_nurture_sequence_emails`)
- Reference: `convex/expenses/queries.ts` and `convex/expenses/mutations.ts` (pattern to follow)

**Step 1: Read the nurture schema**

Read `convex/schema/nurture.ts` to understand all 4 tables, their fields, and indexes.

**Step 2: Create queries.ts**

Create `convex/nurture/queries.ts` with these queries (follow the pattern in `convex/expenses/queries.ts`):

- `getSequences` — list all sequences, optional filter by status/trigger
- `getSequenceById` — single sequence by ID
- `getSequenceSteps` — steps for a sequence, ordered by stepOrder
- `getUserStates` — enrollment states for a user
- `getUserStateBySequence` — single state for user+sequence combo
- `getEmailsByState` — emails for a user nurture state
- `getEmailStats` — aggregate sent/opened/clicked/failed counts for a sequence

Each query should use the indexes defined in the schema (e.g., `by_status`, `by_sequenceId`, `by_userId`).

**Step 3: Create mutations.ts**

Create `convex/nurture/mutations.ts` with:

- `createSequence` / `updateSequence` / `deleteSequence`
- `createStep` / `updateStep` / `deleteStep` / `reorderSteps`
- `enrollUser` / `pauseUser` / `resumeUser` / `cancelUser` / `completeUser`
- `createEmail` / `updateEmailStatus` / `markEmailSent` / `markEmailOpened` / `markEmailClicked`

**Step 4: Push to Convex and verify**

Run: `CONVEX_DEPLOYMENT=dev:tacit-chinchilla-978 npx convex dev --once`
Expected: No errors, functions registered.

**Step 5: Commit**

```bash
git add convex/nurture/
git commit -m "feat(convex): implement nurture domain queries and mutations"
```

---

### Task 2: Customers Schema — Extract to Own File

Customers table is defined inside `convex/schema/bookings.ts` (lines 100-119) instead of its own file.

**Files:**

- Create: `convex/schema/customers.ts`
- Modify: `convex/schema/bookings.ts` — remove customers table definition
- Modify: `convex/schema.ts` — add `customerTables` import

**Step 1: Create `convex/schema/customers.ts`**

Extract the customers table definition from `convex/schema/bookings.ts` lines 100-119 into a new file. Follow the pattern of other schema files (import `defineTable`, `v` from `convex/server` and `convex/values`). Export as `customerTables`.

**Step 2: Remove from bookings.ts**

Delete the `customers` table definition from `convex/schema/bookings.ts`.

**Step 3: Update schema.ts**

Add `import { customerTables } from './schema/customers'` and spread `...customerTables` into the schema definition in `convex/schema.ts`.

**Step 4: Push and verify**

Run: `CONVEX_DEPLOYMENT=dev:tacit-chinchilla-978 npx convex dev --once`
Expected: No errors, same schema.

**Step 5: Commit**

```bash
git add convex/schema/customers.ts convex/schema/bookings.ts convex/schema.ts
git commit -m "refactor(convex): extract customers schema to dedicated file"
```

---

### Task 3: Expenses Domain — Wire Remaining API Routes

Only 1 of the expense API routes is wired to Convex. Functions exist in `convex/expenses/` (queries.ts: 43 lines, mutations.ts: 119 lines).

**Files:**

- Modify: `app/api/dashboard/expenses/route.ts` (117 lines) — ensure GET and POST use Convex
- Search for any other expense-related routes in `app/api/`

**Step 1: Read existing route and Convex functions**

Read `app/api/dashboard/expenses/route.ts`, `convex/expenses/queries.ts`, and `convex/expenses/mutations.ts` to understand current wiring.

**Step 2: Wire remaining handlers**

For each HTTP method handler (GET, POST, PUT, DELETE) in the expense route:

- Remove `getDataSource('expenses')` conditional
- Call Convex directly via `convexQuery()` or `convexMutation()` using `api.expenses.queries.*` / `api.expenses.mutations.*`
- Remove any Prisma fallback branches

**Step 3: Verify TypeScript compiles**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`
Expected: No new errors.

**Step 4: Commit**

```bash
git add app/api/dashboard/expenses/
git commit -m "feat(convex): wire all expense API routes to Convex"
```

---

### Task 4: Blog Public Pages — Migrate lib/blog.ts to Convex

`lib/blog.ts` (424 lines) reads directly from Supabase. Staff API routes already use Convex. Public blog pages still use Supabase.

**Files:**

- Modify: `lib/blog.ts` (424 lines) — replace all Supabase queries with Convex
- Reference: `convex/content/queries.ts` — existing blog queries
- Reference: `convex/schema/content.ts` — blog_posts table schema

**Step 1: Read current blog.ts and Convex content queries**

Read `lib/blog.ts`, `convex/content/queries.ts`, and `convex/schema/content.ts`.

**Step 2: Check if Convex content queries cover all blog.ts functions**

`lib/blog.ts` exports: `getAllPosts`, `getPostBySlug`, `getPaginatedPosts`, `getAllTags`, `getPostsByTag`, `getRelatedPosts`, `getAllPostSlugs`. Check which Convex queries exist and which need creating.

**Step 3: Add any missing Convex content queries**

If any blog functions aren't covered by `convex/content/queries.ts`, add them. Likely needed:

- `getPostBySlug` — single post lookup
- `getPaginatedPosts` — with offset/limit
- `getAllPostSlugs` — for static generation
- `getRelatedPosts` — by tags
- `getPostsByTag` — filter by tag

**Step 4: Rewrite lib/blog.ts**

Replace all `supabase.from('blog_posts')` calls with `convexQuery(api.content.queries.*)`. Keep `unstable_cache` wrappers for Next.js caching. Remove `import { supabase } from './supabase'`.

Map field names: Supabase uses `snake_case` (cover_image, reading_time_minutes), Convex may use `camelCase`. Handle in the mapping functions already in blog.ts.

**Step 5: Verify TypeScript compiles**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 6: Commit**

```bash
git add lib/blog.ts convex/content/queries.ts
git commit -m "feat(convex): migrate public blog pages from Supabase to Convex"
```

---

### Task 5: Wire Remaining API Routes to Convex (Batch by Domain)

~47% of routes (~183) still import Prisma directly. Process domain by domain.

**Strategy:** For each domain, find all API routes importing `lib/generated/prisma`, `@prisma/client`, or `lib/dual-database`. Replace with direct Convex calls. Remove data-source conditionals.

**Files importing Prisma client (31 API routes):**

- `app/api/auth/verify-email-only/route.ts`
- `app/api/auth/verify-otp-and-activate/route.ts`
- `app/api/bookings/route.ts`
- `app/api/conversations/route.ts`
- `app/api/dashboard/reviews/questions/**` (3 routes)
- `app/api/demo/bookings/route.ts`
- `app/api/notifications/route.ts`
- `app/api/real-estate/**` (12 routes)
- `app/api/site/**` (7 routes)
- `app/api/support/faqs/route.ts`
- `app/api/voice-agent/**` (5 routes)

**Files importing dual-database (42 API routes):**

- All `app/api/staff/dashboard/**` routes using `saveToBoth`
- `app/api/webhooks/n8n/**` routes
- `app/api/waitlists/**` routes
- `app/api/user/customers/**` routes
- `app/api/leads/**` routes
- See full list in import analysis

**Step 1: Process by domain — auth routes (2 files)**

For `verify-email-only` and `verify-otp-and-activate`: replace `prisma.user.update()` with `convexMutation(api.auth.mutations.updateUser, {...})`.

**Step 2: Process by domain — bookings + demo (3 files)**

Replace Prisma booking queries with `convexQuery(api.bookings.queries.*)`.

**Step 3: Process by domain — real estate (12 files)**

These already have Convex functions (`convex/real_estate/` has 18 queries, 14 mutations). Remove Prisma imports, call Convex directly.

**Step 4: Process by domain — voice agents (5 files)**

Replace Prisma imports with Convex calls via `api.voice_agents.*`.

**Step 5: Process by domain — site content (7 files)**

Replace Prisma imports with Convex calls via `api.content.*`.

**Step 6: Process by domain — support (1 file)**

Replace Prisma FAQ queries with Convex calls.

**Step 7: Process by domain — reviews (3 files)**

Replace Prisma review question queries with Convex calls.

**Step 8: Process by domain — conversations + notifications (2 files)**

Replace with Convex calls.

**Step 9: Process dual-database imports (42 files)**

For each file importing `lib/dual-database`:

- Remove `import { saveFooToBoth } from '@/lib/dual-database'`
- Replace `saveFooToBoth(data)` with `convexMutation(api.<domain>.mutations.createFoo, data)`
- Remove any Supabase sync error handling (no longer needed)

**Step 10: Process lib service files (10 files importing dual-database)**

Same pattern: replace `saveToBoth` calls with direct Convex mutations in:

- `lib/services/billing-emails.service.ts`
- `lib/services/conversation-tracker.service.ts`
- `lib/services/gemini-rag.service.ts`
- `lib/services/knowledge-base.service.ts`
- `lib/services/organization.service.ts`
- `lib/services/user-management.service.ts`
- `lib/services/webhook-provisioning.service.ts`
- `lib/services/whatsapp-ai-engine.service.ts`
- `lib/services/whatsapp-knowledge-base.service.ts`
- `lib/services/workflow-management.service.ts`

**Step 11: Process lib files importing Prisma (26 files)**

Replace `prisma.tableName.findMany/create/update/delete` with Convex equivalents in:

- `lib/services/deals.service.ts`, `lib/services/expenses.service.ts`, `lib/services/quotations.service.ts`
- `lib/services/reviews.service.ts`, `lib/services/social-calendar.service.ts`
- `lib/services/lead-*.service.ts` (5 files)
- `lib/services/notifications.service.ts`
- `lib/site-content/*.ts` (3 files)
- `lib/support-faqs/*.ts` (4 files)
- `lib/utils/auth-helpers.util.ts`, `lib/utils/permissions.ts`
- `lib/api/billing-history.ts`, `lib/api/subscriptions.ts`
- `lib/auth.ts`, `lib/otp-helper.ts`

**Step 12: Process Supabase imports in lib services (11 files)**

Replace `supabase.from('table').select/insert/update` with Convex calls in:

- `lib/services/admin-notes.service.ts`
- `lib/services/api-key-management.service.ts`
- `lib/services/billing-emails.service.ts`
- `lib/services/billing-service.ts`
- `lib/services/booking.service.ts`
- `lib/services/interview-bookings.service.ts`
- `lib/services/user-management.service.ts`
- `lib/staff-dashboard/demo-bookings/index.ts`
- `lib/staff-dashboard/demo-bookings/queries.ts`
- `lib/queue/workers/analytics.worker.ts`
- `lib/utils/voice-agent-db.ts`

**Step 13: Process Supabase imports in API routes (35 files)**

Replace Supabase table queries with Convex in all API routes importing `lib/supabase`. See full list in import analysis. Key groups:

- Onboarding routes (4 files): company-info, complete, dismiss, progress
- Billing routes (3 files): payment verify, checkout, subscriptions
- Staff routes (13 files): demos, careers, users, jobs
- Booking routes (5 files): mark-paid, reschedule, status, verify-payment

**Step 14: Process component Supabase imports (2 files)**

- `components/contact/SmartContactForm.tsx` — remove Supabase type imports
- `components/ContactForm.tsx` — remove Supabase type imports

These likely only import types (for form field definitions). Replace with local type definitions or Convex-generated types.

**Step 15: Verify no remaining Prisma/Supabase/dual-db imports**

Run:

```bash
grep -r "from.*lib/dual-database\|from.*lib/generated/prisma\|from.*@prisma/client\|from.*lib/supabase\|from.*@supabase" --include="*.ts" --include="*.tsx" -l | grep -v __tests__ | grep -v node_modules
```

Expected: Zero results (excluding test files — handled in Task 23).

**Step 16: TypeScript check**

Run: `npx tsc --noEmit --pretty 2>&1 | head -100`

**Step 17: Commit (do this after each domain batch, not all at once)**

Commit after each domain group (Steps 1-8 each get a commit, Steps 9-14 get grouped commits).

---

## Phase 2: Convex Storage Migration

### Task 6: Convex Storage Infrastructure

Create the shared storage schema and actions.

**Files:**

- Create: `convex/schema/storage.ts` — file metadata table
- Modify: `convex/schema.ts` — import storage tables
- Create: `convex/storage/mutations.ts` — upload URL generation, file deletion
- Create: `convex/storage/queries.ts` — file URL retrieval, metadata lookup

**Step 1: Create storage schema**

Create `convex/schema/storage.ts`:

```typescript
import { defineTable } from "convex/server";
import { v } from "convex/values";

export const storageTables = {
  file_metadata: defineTable({
    storageId: v.id("_storage"),
    originalFilename: v.string(),
    mimeType: v.string(),
    sizeBytes: v.number(),
    bucket: v.string(), // "contact-attachments" | "job-cvs" | "blog-images" | "avatars" | "attachments"
    associatedTable: v.optional(v.string()), // "contact_submissions" | "job_applications" | etc.
    associatedId: v.optional(v.id("contact_submissions")),
    uploadedBy: v.optional(v.string()),
    createdAt: v.number(),
  })
    .index("by_storageId", ["storageId"])
    .index("by_bucket", ["bucket"])
    .index("by_associatedTable_associatedId", [
      "associatedTable",
      "associatedId",
    ]),
};
```

**Step 2: Update schema.ts**

Add `import { storageTables } from './schema/storage'` and spread into schema.

**Step 3: Create storage mutations**

Create `convex/storage/mutations.ts`:

- `generateUploadUrl` — calls `ctx.storage.generateUploadUrl()`
- `saveFileMetadata` — saves metadata after upload completes
- `deleteFile` — calls `ctx.storage.delete(storageId)` and removes metadata

**Step 4: Create storage queries**

Create `convex/storage/queries.ts`:

- `getFileUrl` — calls `ctx.storage.getUrl(storageId)`, returns URL string
- `getFileMetadata` — returns metadata record
- `getFilesByAssociation` — returns all files for a given table+id

**Step 5: Push and verify**

Run: `CONVEX_DEPLOYMENT=dev:tacit-chinchilla-978 npx convex dev --once`

**Step 6: Commit**

```bash
git add convex/schema/storage.ts convex/schema.ts convex/storage/
git commit -m "feat(convex): add file storage infrastructure"
```

---

### Task 7: Contact Attachments — Migrate to Convex Storage

**Files:**

- Modify: `app/api/contact/submit/route.ts` (668 lines)
- Reference: `lib/security/file-validation.ts` — keep as-is (validation logic)

**Step 1: Read the current upload flow**

Read `app/api/contact/submit/route.ts` to understand the Supabase storage upload flow. Key operations:

- `supabaseAdmin.storage.from('contact-attachments').upload(path, buffer)`
- `storageClient.createSignedUrl(path, 3600)` — 1-hour signed URL
- `supabaseAdmin.storage.from('contact-attachments').remove([path])` — rollback on failure

**Step 2: Replace upload flow**

Replace Supabase storage with Convex:

1. Server gets the file buffer from FormData (keep existing validation)
2. Call `convexAction` for a new action that accepts the buffer and stores it via `ctx.storage.store(blob)`
3. Save metadata via `convexMutation(api.storage.mutations.saveFileMetadata, ...)`
4. Get URL via `convexQuery(api.storage.queries.getFileUrl, { storageId })`

Note: Convex file upload from server-side requires an HTTP action. Create `convex/storage/actions.ts` with a `storeFile` action that accepts a Blob/ArrayBuffer.

**Step 3: Replace signed URL pattern**

Convex storage URLs are public and permanent (no expiry). Replace `createSignedUrl(path, 3600)` with `ctx.storage.getUrl(storageId)`. If access control is needed, create an HTTP action that checks auth before redirecting to the storage URL.

**Step 4: Replace rollback/delete**

Replace `supabaseAdmin.storage.from(...).remove([path])` with `convexMutation(api.storage.mutations.deleteFile, { storageId })`.

**Step 5: Remove Supabase imports**

Remove `import { supabaseAdmin, CONTACT_ATTACHMENTS_BUCKET } from '@/lib/supabase'` from this route.

**Step 6: Verify TypeScript compiles**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 7: Commit**

```bash
git add app/api/contact/submit/route.ts convex/storage/
git commit -m "feat(convex): migrate contact attachments to Convex storage"
```

---

### Task 8: Job CVs — Migrate to Convex Storage

**Files:**

- Modify: `app/api/apply-job/route.ts` (231 lines) — upload
- Modify: `app/api/staff/applications/[id]/download-cv/route.ts` — download

**Step 1: Replace CV upload**

In `app/api/apply-job/route.ts`:

- Replace `supabaseAdmin.storage.from('job-cvs').upload(path, buffer)` with Convex storage action
- Store Convex storage ID in the job application record instead of `cv_bucket`/`cv_path`
- Keep PDF validation, size checks, MIME type checks
- Remove `import { supabaseAdmin, JOB_CV_BUCKET } from '@/lib/supabase'`

**Step 2: Replace CV download**

In the download-cv route:

- Replace `supabaseAdmin.storage.from('job-cvs').download(path)` with `convexQuery(api.storage.queries.getFileUrl, { storageId })`
- Fetch the file from the Convex URL and stream it as response
- Or redirect to the Convex storage URL directly

**Step 3: Verify and commit**

```bash
git add app/api/apply-job/route.ts app/api/staff/applications/
git commit -m "feat(convex): migrate job CV storage to Convex"
```

---

### Task 9: Blog Images — Migrate to Convex Storage

**Files:**

- Modify: `lib/services/blog-management.service.ts` (557 lines)

**Step 1: Replace image upload**

Replace `uploadImageToStorage()` function:

- Remove `supabaseAdmin.storage.from('blog-images').upload()`
- Use Convex storage action to store AI-generated image buffer
- Return Convex storage URL instead of Supabase public URL

**Step 2: Replace image URL retrieval**

Replace `supabaseAdmin.storage.from('blog-images').getPublicUrl()` with `convexQuery(api.storage.queries.getFileUrl, { storageId })`.

**Step 3: Replace image deletion**

Replace `supabaseAdmin.storage.from('blog-images').remove()` with `convexMutation(api.storage.mutations.deleteFile, { storageId })`.

**Step 4: Remove Supabase imports**

Remove `import { supabaseAdmin } from '@/lib/supabase'` and `const BLOG_IMAGES_BUCKET = 'blog-images'`.

**Step 5: Commit**

```bash
git add lib/services/blog-management.service.ts
git commit -m "feat(convex): migrate blog images to Convex storage"
```

---

### Task 10: User Avatars — Migrate to Convex Storage

**Files:**

- Modify: `app/api/user/avatar/route.ts` (80 lines)

**Step 1: Replace local filesystem upload**

Remove `writeFile()` / `mkdir()` usage. Replace with Convex storage action. The route already imports `convexMutation` and `api` — extend to use storage.

**Step 2: Update avatar URL in user record**

Instead of `/uploads/avatars/{filename}`, store the Convex storage URL in the user record.

**Step 3: Remove fs imports**

Remove `import { writeFile, mkdir } from "fs/promises"` and `import { join } from "path"`.

**Step 4: Commit**

```bash
git add app/api/user/avatar/route.ts
git commit -m "feat(convex): migrate avatar uploads to Convex storage"
```

---

### Task 11: Email Attachments — Migrate to Convex Storage

**Files:**

- Modify: `app/api/upload/attachments/route.ts`

**Step 1: Replace local filesystem upload**

Same pattern as Task 10. Remove `fs.writeFile()`, use Convex storage action. Return Convex URL instead of `/uploads/attachments/{filename}`.

**Step 2: Commit**

```bash
git add app/api/upload/attachments/route.ts
git commit -m "feat(convex): migrate email attachments to Convex storage"
```

---

### Task 12: Existing File Data Migration Script

**Files:**

- Create: `scripts/migrate-storage-to-convex.ts`

**Step 1: Write migration script**

Script that:

1. Connects to Supabase (one-time use, hardcode credentials)
2. Lists all files in each bucket (`contact-attachments`, `job-cvs`, `blog-images`)
3. Downloads each file
4. Uploads to Convex storage via HTTP action
5. Updates the associated DB record (contact submission, job application, blog post) with new Convex storage ID
6. Logs progress and errors

**Step 2: Test with a single file**

Run script in dry-run mode against one file per bucket.

**Step 3: Run full migration**

Execute against all files.

**Step 4: Verify all files accessible**

Spot-check 5 files from each bucket via Convex URLs.

**Step 5: Commit**

```bash
git add scripts/migrate-storage-to-convex.ts
git commit -m "feat: add one-time storage migration script (Supabase → Convex)"
```

---

## Phase 3: Redis to Convex Migration

### Task 13: OTP Storage — Move to Convex

**Files:**

- Modify: `lib/otp-storage.ts` (386 lines)
- Reference: `convex/schema/auth.ts` — `otps` table already exists (lines 85-100)
- Reference: `convex/auth/mutations.ts` — check if OTP mutations exist
- Create (if needed): OTP-specific mutations in `convex/auth/mutations.ts`

**Step 1: Check existing Convex OTP functions**

Read `convex/auth/mutations.ts` and `convex/auth/queries.ts` for any OTP-related functions. If none exist, create:

- `createOtp` — insert with email, hashed code, purpose, expiresAt
- `getOtpByEmail` — lookup by email, filter by not-expired
- `verifyOtp` — mark verified, increment attempts
- `deleteOtp` — remove after successful use
- `incrementOtpAttempts` — track brute-force attempts

**Step 2: Create scheduled cleanup function**

Create `convex/auth/crons.ts` (or add to existing):

```typescript
// Scheduled function to purge expired OTPs
// Runs every hour, deletes all OTPs where expires_at < Date.now()
```

Register in `convex/crons.ts` using `crons.interval("purge expired OTPs", { hours: 1 }, ...)`.

**Step 3: Rewrite lib/otp-storage.ts**

Replace all Redis operations:

- `redis.setex(key, OTP_EXPIRY_SECONDS, hashedOtp)` → `convexMutation(api.auth.mutations.createOtp, { email, otp_code: hashedOtp, purpose, expires_at: Date.now() + OTP_EXPIRY_SECONDS * 1000 })`
- `redis.get(key)` → `convexQuery(api.auth.queries.getOtpByEmail, { email })`
- `redis.del(key)` → `convexMutation(api.auth.mutations.deleteOtp, { email })`
- `redis.incr(attemptKey)` → `convexMutation(api.auth.mutations.incrementOtpAttempts, { email })`

Keep: bcrypt hashing, OTP generation, email sending. Only storage layer changes.

**Step 4: Remove Redis imports**

Remove `import { redis } from './redis'` from `lib/otp-storage.ts`.

**Step 5: Update all OTP consumers (7 API routes)**

These files import `lib/otp-storage` — verify they still work:

- `app/api/auth/forgot-password/route.ts`
- `app/api/auth/google-signup/route.ts`
- `app/api/auth/verify-otp-and-activate/route.ts`
- `app/api/auth/verify-reset-otp/route.ts`
- `app/api/auth/verify-signup-otp/route.ts`
- `app/api/send-otp/route.ts`
- `app/api/test-redis/route.ts` (delete this — test endpoint for Redis)

**Step 6: Verify and commit**

```bash
git add lib/otp-storage.ts convex/auth/
git commit -m "feat(convex): migrate OTP storage from Redis to Convex"
```

---

### Task 14: Secure Tokens — Move to Convex

**Files:**

- Modify: `lib/secure-tokens.ts` (321 lines)

**Step 1: Add token tracking to Convex**

Add to `convex/auth/mutations.ts`:

- `createResetToken` — store `{ tokenId, email, consumed: false, expiresAt }`
- `consumeResetToken` — set `consumed: true`, return success/failure
- `getResetToken` — lookup by tokenId, check not consumed and not expired

Can reuse the `otps` table with an additional `tokenId` field, or create a `reset_tokens` table in `convex/schema/auth.ts`.

**Step 2: Rewrite token storage in lib/secure-tokens.ts**

Replace:

- `redis.set(redisKey, identifier, { ex: 600 })` → `convexMutation(api.auth.mutations.createResetToken, { tokenId, email, expiresAt })`
- `redis.get(redisKey)` → `convexQuery(api.auth.queries.getResetToken, { tokenId })`
- `redis.del(redisKey)` → `convexMutation(api.auth.mutations.consumeResetToken, { tokenId })`

Keep: JWT signing/verification (jose library), crypto UUID generation.

**Step 3: Remove Redis import**

Remove `import { redis } from './redis'`.

**Step 4: Commit**

```bash
git add lib/secure-tokens.ts convex/auth/
git commit -m "feat(convex): migrate secure token tracking from Redis to Convex"
```

---

### Task 15: Rate Limiting — Move to Convex

**Files:**

- Modify: `lib/rate-limiter.ts` (315 lines)
- Modify: `lib/rate-limit.ts` (68 lines)
- Create (if needed): `convex/schema/rate_limiting.ts`
- Create: `convex/rate_limiting/mutations.ts`
- Create: `convex/rate_limiting/queries.ts`

**Step 1: Create rate limiting schema**

Create `convex/schema/rate_limiting.ts`:

```typescript
export const rateLimitTables = {
  rate_limit_windows: defineTable({
    key: v.string(), // "ip:192.168.1.1" or "email:user@example.com"
    limiterType: v.string(), // "otp_verify", "login", "register", etc.
    count: v.number(),
    windowStart: v.number(), // unix timestamp
    windowEnd: v.number(), // unix timestamp
  })
    .index("by_key_type", ["key", "limiterType"])
    .index("by_windowEnd", ["windowEnd"]),
};
```

**Step 2: Create rate limiting functions**

`convex/rate_limiting/queries.ts`:

- `checkRateLimit` — query current window count for key+type, return { allowed: boolean, remaining: number, resetAt: number }

`convex/rate_limiting/mutations.ts`:

- `incrementRateLimit` — upsert window count, create new window if expired
- `resetRateLimit` — delete window (for admin override)

**Step 3: Rewrite lib/rate-limiter.ts**

Replace `@upstash/ratelimit` Ratelimit instances with Convex-backed sliding window:

```typescript
export async function checkRateLimit(
  key: string,
  type: string,
  limit: number,
  windowMs: number,
): Promise<{ success: boolean; remaining: number }> {
  const result = await convexQuery(api.rate_limiting.queries.checkRateLimit, {
    key,
    limiterType: type,
    limit,
    windowMs,
  });
  if (result.allowed) {
    await convexMutation(api.rate_limiting.mutations.incrementRateLimit, {
      key,
      limiterType: type,
      windowMs,
    });
  }
  return { success: result.allowed, remaining: result.remaining };
}
```

Keep the same rate limit configurations (5 attempts/15min for OTP, 10/5min for login, etc.).

**Step 4: Add cleanup cron**

Add to `convex/crons.ts`: purge expired rate limit windows every hour.

**Step 5: Update lib/rate-limit.ts (middleware)**

Replace Redis/Upstash middleware rate limiting with the same Convex-backed function.

**Step 6: Remove Upstash imports**

Remove `import { Ratelimit } from "@upstash/ratelimit"` and `import { Redis } from "@upstash/redis"` from rate limiter files.

**Step 7: Verify all 71 API routes still build**

Run: `npx tsc --noEmit --pretty 2>&1 | head -100`

**Step 8: Commit**

```bash
git add lib/rate-limiter.ts lib/rate-limit.ts convex/rate_limiting/ convex/schema/rate_limiting.ts convex/schema.ts convex/crons.ts
git commit -m "feat(convex): migrate rate limiting from Redis/Upstash to Convex"
```

---

### Task 16: Cache — Replace with In-Memory LRU

**Files:**

- Modify: `lib/cache.ts` (203 lines)
- Consumers (6 lib files): `lib/cache/examples.ts`, `lib/cache/redis-cache.ts`, `lib/services/exchange-rate.service.ts`, `lib/services/knowledge-base.service.ts`, `lib/services/pricing.service.ts`, `lib/services/user-currency.service.ts`

**Step 1: Install lru-cache**

Run: `npm install lru-cache`

**Step 2: Rewrite lib/cache.ts**

Replace Redis-backed cache with `lru-cache`:

```typescript
import { LRUCache } from "lru-cache";

const cache = new LRUCache<string, string>({
  max: 500,
  ttl: 3600 * 1000, // 1 hour default
});

export const appCache = {
  async get<T>(key: string): Promise<T | null> {
    const val = cache.get(key);
    return val ? (JSON.parse(val) as T) : null;
  },
  async set<T>(key: string, value: T, ttlSeconds = 3600): Promise<void> {
    cache.set(key, JSON.stringify(value), { ttl: ttlSeconds * 1000 });
  },
  async del(key: string): Promise<void> {
    cache.delete(key);
  },
  async getOrSet<T>(
    key: string,
    fetcher: () => Promise<T>,
    ttlSeconds = 3600,
  ): Promise<T> {
    const existing = await this.get<T>(key);
    if (existing !== null) return existing;
    const value = await fetcher();
    await this.set(key, value, ttlSeconds);
    return value;
  },
};
```

Keep the same export API so consumers don't need changes. Remove `import { redis, redisConfigured } from './redis'`.

**Step 3: Delete lib/cache/redis-cache.ts**

This file is now unnecessary — the main cache.ts handles everything.

**Step 4: Verify consumers compile**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 5: Commit**

```bash
git add lib/cache.ts
git rm lib/cache/redis-cache.ts
git commit -m "refactor: replace Redis cache with in-memory LRU cache"
```

---

### Task 17: Health Checks — Remove Redis + Supabase + Prisma Checks

**Files:**

- Modify: `lib/health-checks.ts` (482 lines)
- Modify or delete: `app/api/health/redis/route.ts` (if exists)
- Modify: `app/api/health/database/route.ts`
- Modify: `app/api/status/route.ts`

**Step 1: Rewrite health-checks.ts**

Remove:

- `checkDatabase()` — Prisma `$queryRaw`
- `checkRedis()` — Redis ping
- `checkSupabase()` — Supabase connectivity

Replace with:

- `checkConvex()` — simple Convex query (e.g., `convexQuery(api.auth.queries.healthCheck, {})`)
- `checkUpstash()` — if BullMQ Redis is configured, ping it

Remove imports: `prisma`, `redis`, `createClient` from `@supabase/supabase-js`.

**Step 2: Update health API routes**

Update `/api/health/*` routes to call new health check functions.

**Step 3: Commit**

```bash
git add lib/health-checks.ts app/api/health/
git commit -m "refactor: update health checks for Convex-only stack"
```

---

### Task 18: BullMQ — Switch to Upstash Redis

**Files:**

- Modify: `lib/queue/bullmq.ts` (661 lines)
- Modify: `lib/redis.ts` (143 lines) — simplify to Upstash-only

**Step 1: Simplify lib/redis.ts**

Remove the IORedis fallback path. Keep only Upstash:

```typescript
import { Redis } from "@upstash/redis";

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const redisConfigured = Boolean(
  process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN,
);
```

Remove `import IORedis from 'ioredis'` and the `UpstashCompatibleRedis` wrapper class.

**Step 2: Update BullMQ connection**

BullMQ requires IORedis (not Upstash REST). Two options:

- **Option A**: Use `@upstash/redis` with `ioredis` compatibility layer (`@upstash/ioredis`)
- **Option B**: Keep `ioredis` for BullMQ only, connecting to Upstash's Redis-compatible endpoint

Choose Option B (simpler, BullMQ is designed for IORedis):

```typescript
import IORedis from "ioredis";

function getRedisConnection(): IORedis {
  // Upstash provides a standard Redis URL for IORedis compatibility
  const redisUrl = process.env.UPSTASH_REDIS_URL || process.env.REDIS_URL;
  if (!redisUrl) throw new Error("UPSTASH_REDIS_URL required for job queues");
  return new IORedis(redisUrl, {
    maxRetriesPerRequest: null,
    enableReadyCheck: false,
  });
}
```

Note: This means `ioredis` stays as a dependency (BullMQ requires it). Update the design doc to reflect this.

**Step 3: Remove local Redis fallback**

Remove the `redis://localhost:6379` default. BullMQ now requires an explicit Upstash URL.

**Step 4: Commit**

```bash
git add lib/redis.ts lib/queue/bullmq.ts
git commit -m "refactor: simplify Redis to Upstash-only, update BullMQ connection"
```

---

## Phase 4: Remove Migration Scaffolding

### Task 19: Delete Dual-Database + Sync Queue + Database Sync

**Files:**

- Delete: `lib/dual-database.ts` (1,482 lines)
- Delete: `lib/sync-queue.ts` (272 lines)
- Delete: `lib/database-sync/` directory (7 files, 1,960 lines)
- Delete: `lib/services/dlq-management.service.ts` (imports sync-queue)

**Pre-requisite:** All imports from these files must already be removed (Task 5).

**Step 1: Verify no remaining imports**

Run:

```bash
grep -r "from.*lib/dual-database\|from.*lib/sync-queue\|from.*lib/database-sync\|from.*dlq-management" --include="*.ts" --include="*.tsx" -l | grep -v __tests__ | grep -v node_modules
```

Expected: Zero results.

**Step 2: Delete files**

```bash
rm lib/dual-database.ts lib/sync-queue.ts
rm -rf lib/database-sync/
rm lib/services/dlq-management.service.ts
```

**Step 3: Delete sync queue API routes**

```bash
rm app/api/cron/sync-queue/route.ts
```

**Step 4: Verify TypeScript**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 5: Commit**

```bash
git add -A
git commit -m "chore: delete dual-database sync infrastructure (3,714 lines)"
```

---

### Task 20: Delete Data Source Router

**Files:**

- Delete: `lib/data-source.ts` (62 lines)

**Pre-requisite:** All `getDataSource()` / `isConvex()` / `isPrisma()` calls removed (Task 5).

**Step 1: Verify no remaining imports**

```bash
grep -r "from.*lib/data-source" --include="*.ts" --include="*.tsx" -l | grep -v __tests__ | grep -v node_modules
```

Expected: Zero results.

**Step 2: Delete**

```bash
rm lib/data-source.ts
```

**Step 3: Commit**

```bash
git add -A
git commit -m "chore: delete data-source router (migration complete)"
```

---

### Task 21: Delete Prisma

**Files:**

- Delete: `prisma/schema.prisma` (3,956 lines)
- Delete: `prisma/migrations/` directory
- Delete: `prisma/seeds/` directory
- Delete: `lib/generated/prisma/` directory
- Delete: `lib/db.ts` (if it exports the Prisma client)
- Modify: `package.json` — remove `prisma`, `@prisma/client`

**Step 1: Verify no remaining Prisma imports**

```bash
grep -r "from.*lib/generated/prisma\|from.*@prisma/client\|from.*lib/db" --include="*.ts" --include="*.tsx" -l | grep -v __tests__ | grep -v node_modules
```

**Step 2: Delete Prisma files**

```bash
rm -rf prisma/
rm -rf lib/generated/prisma/
rm -f lib/db.ts
```

**Step 3: Remove from package.json**

```bash
npm uninstall prisma @prisma/client
```

**Step 4: Remove Prisma scripts from package.json**

Remove any `prisma:*` scripts (generate, migrate, studio, etc.).

**Step 5: Verify TypeScript**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 6: Commit**

```bash
git add -A
git commit -m "chore: delete Prisma schema, migrations, and generated client"
```

---

### Task 22: Delete Supabase

**Files:**

- Delete: `lib/supabase.ts` (236 lines)
- Delete: `supabase/` directory (config.toml, 57 migrations, seed.sql)
- Modify: `package.json` — remove `@supabase/supabase-js`

**Step 1: Verify no remaining Supabase imports**

```bash
grep -r "from.*lib/supabase\|from.*@supabase" --include="*.ts" --include="*.tsx" -l | grep -v __tests__ | grep -v node_modules
```

**Step 2: Delete Supabase files**

```bash
rm lib/supabase.ts
rm -rf supabase/
```

**Step 3: Remove from package.json**

```bash
npm uninstall @supabase/supabase-js
```

**Step 4: Remove Upstash ratelimit**

```bash
npm uninstall @upstash/ratelimit
```

**Step 5: Verify TypeScript**

Run: `npx tsc --noEmit --pretty 2>&1 | head -50`

**Step 6: Commit**

```bash
git add -A
git commit -m "chore: delete Supabase client, migrations, and config"
```

---

### Task 23: Test Cleanup

**Files:**

- 124 test files in `__tests__/` import from deleted modules

**Step 1: Identify all affected tests**

```bash
grep -r "from.*lib/dual-database\|from.*lib/sync-queue\|from.*lib/supabase\|from.*@supabase\|from.*lib/generated/prisma\|from.*@prisma/client\|from.*lib/data-source" --include="*.ts" --include="*.tsx" -l __tests__/
```

**Step 2: Categorize tests**

- **Delete entirely**: Tests that ONLY test dual-db sync, Prisma-specific behavior, Supabase-specific behavior, or data-source routing logic
- **Rewrite**: Tests that validate business logic but happen to use Prisma/Supabase — update to mock Convex instead
- **Keep as-is**: Tests that don't import deleted modules

**Step 3: Delete pure infrastructure tests**

Delete tests in:

- `__tests__/database/` (sync verification, schema validation vs Prisma)
- `__tests__/integration/database-sync.integration.test.ts`
- `__tests__/integration/dual-database-user-management.test.ts`
- `__tests__/lib/dual-database.test.ts`
- `__tests__/lib/data-source.test.ts`
- `__tests__/database/supabase-schema-integrity.test.ts`

**Step 4: Update remaining tests**

For tests that import Prisma/Supabase for mocking:

- Replace `jest.mock('@/lib/generated/prisma')` with `jest.mock('@/lib/convex-client')`
- Replace `prisma.user.findUnique()` mocks with `convexQuery()` mocks
- Replace `supabase.from().select()` mocks with `convexQuery()` mocks

**Step 5: Update mock files**

- `__mocks__/lib/db.ts` → delete or replace with Convex client mock
- `__mocks__/lib/db.js` → delete

**Step 6: Run tests**

```bash
npm run test -- --passWithNoTests 2>&1 | tail -30
```

**Step 7: Commit**

```bash
git add -A
git commit -m "test: clean up tests for Convex-only stack"
```

---

## Phase 5: Remove Docker from Dev and CI

### Task 24: Delete Docker Dev Files

**Files:**

- Delete: `Dockerfile.dev` (85 lines)
- Delete: `Dockerfile.simple` (51 lines)
- Delete: `docker-compose.yml` (254 lines)
- Delete: `docker-compose.infra.yml` (104 lines)
- Delete: `docker-compose.override.yml` (empty)
- Delete: `scripts/docker-infra.sh`
- Delete: `scripts/docker-status.sh`
- Delete: `scripts/docker-logging.sh`
- Keep: `Dockerfile` (production), `scripts/docker-entrypoint.sh` (used by prod Dockerfile)

**Step 1: Delete dev Docker files**

```bash
rm Dockerfile.dev Dockerfile.simple
rm docker-compose.yml docker-compose.infra.yml docker-compose.override.yml
rm scripts/docker-infra.sh scripts/docker-status.sh scripts/docker-logging.sh
```

**Step 2: Delete .env.docker files**

```bash
rm .env.docker .env.docker.example
```

**Step 3: Commit**

```bash
git add -A
git commit -m "chore: remove Docker dev infrastructure files"
```

---

### Task 25: Update Production Dockerfile

**Files:**

- Modify: `Dockerfile` (202 lines)

**Step 1: Read current Dockerfile**

Read `Dockerfile` to understand build args and env vars.

**Step 2: Remove Prisma/Supabase references**

- Remove `COPY prisma ./prisma` and `RUN npx prisma generate`
- Remove `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` build args
- Remove `DATABASE_URL` references

**Step 3: Add Convex/Clerk build args**

Add:

- `ARG NEXT_PUBLIC_CONVEX_URL`
- `ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- `ARG CONVEX_DEPLOYMENT`

**Step 4: Commit**

```bash
git add Dockerfile
git commit -m "chore: update production Dockerfile for Convex stack"
```

---

### Task 26: Update npm Scripts

**Files:**

- Modify: `package.json`

**Step 1: Remove Docker scripts**

Remove all 26 `docker:*` scripts from `package.json`:

- `docker:build`, `docker:build:dev`, `docker:run`, `docker:run:dev`
- `docker:compose:*` (up, down, build, logs)
- `docker:infra:*` (start, stop, restart, status, logs, reset)
- `docker:app:*` (dev, prod, stop, restart, rebuild, logs)
- `docker:status`, `docker:migrate`, `docker:validate`, `docker:full`, `docker:down`

**Step 2: Add new dev scripts**

Add:

```json
"dev": "concurrently \"next dev -p 9000\" \"npx convex dev\"",
"dev:next": "next dev -p 9000",
"dev:convex": "npx convex dev"
```

**Step 3: Install concurrently**

```bash
npm install --save-dev concurrently
```

**Step 4: Remove Prisma scripts**

Remove: `prisma:generate`, `prisma:migrate`, `prisma:studio`, `prisma:push`, `postinstall` (if it runs prisma generate).

**Step 5: Commit**

```bash
git add package.json package-lock.json
git commit -m "chore: update npm scripts — remove Docker, add Convex dev"
```

---

### Task 27: Update CI Workflows

**Files:**

- Delete: `.github/workflows/database-tests.yml` (213 lines)
- Modify: `.github/workflows/test.yml`
- Modify: `.github/workflows/dependency-check.yml`
- Modify: `.github/workflows/release-checks.yml`

**Step 1: Delete database-tests.yml**

This workflow spins up Postgres + Supabase for sync tests. No longer needed.

```bash
rm .github/workflows/database-tests.yml
```

**Step 2: Update test.yml**

Remove any Postgres service containers if present. Add Convex env vars to test environment:

```yaml
env:
  NEXT_PUBLIC_CONVEX_URL: ${{ secrets.CONVEX_URL }}
  CONVEX_DEPLOYMENT: ${{ secrets.CONVEX_DEPLOYMENT }}
  NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }}
  CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
```

**Step 3: Update dependency-check.yml**

Remove Prisma/Supabase dependency checks. Add Convex as critical dependency:

```yaml
- name: Verify critical dependencies
  run: node -e "const pkg = require('./package.json'); const critical = ['next', 'react', 'convex', '@clerk/nextjs']; critical.forEach(d => { if (!pkg.dependencies[d]) { console.error(d + ' missing!'); process.exit(1); } });"
```

**Step 4: Update release-checks.yml**

Remove `DATABASE_URL` and Supabase secret checks. Add Convex/Clerk secret checks.

**Step 5: Commit**

```bash
git add -A
git commit -m "ci: update workflows for Convex-only stack"
```

---

### Task 28: Rewrite Environment Validation + Env Files

**Files:**

- Modify: `lib/env-validation.ts` (602 lines)
- Modify: `.env.example`

**Step 1: Rewrite env-validation.ts**

**Critical vars (required):**

- `NEXT_PUBLIC_CONVEX_URL`
- `CONVEX_DEPLOYMENT`
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- `CLERK_SECRET_KEY`
- `NEXTAUTH_SECRET` (keep for JWT signing in secure-tokens)
- `ENCRYPTION_KEY`
- `NODE_ENV`

**Recommended vars:**

- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`
- `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` (for BullMQ)
- `UPSTASH_REDIS_URL` (IORedis-compatible URL for BullMQ)
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`
- `RECAPTCHA_SECRET_KEY`
- `ELEVENLABS_API_KEY`
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`

**Remove:**

- `DATABASE_URL`
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`
- `REDIS_URL` (replaced by Upstash-specific vars)

Update `isFeatureConfigured()` checks:

- Remove `supabase` feature
- Remove `redis` feature (or rename to `bullmq`)
- Add `convex` feature
- Add `clerk` feature

**Step 2: Rewrite .env.example**

Replace entire file with Convex + Clerk + Upstash template:
See `.env.example` in the repo for the full template. Key sections:

- Convex: `NEXT_PUBLIC_CONVEX_URL`, `CONVEX_DEPLOYMENT`
- Clerk: `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`
- Security: `NEXTAUTH_SECRET` (min 32 chars), `ENCRYPTION_KEY` (64 hex)
- Stripe: secret key, webhook secret, publishable key
- Upstash: REST URL + token (for BullMQ), IORedis-compatible URL
- SMTP: host, port, user, pass
- Optional: reCAPTCHA, Google OAuth, ElevenLabs

**Step 3: Commit**

```bash
git add lib/env-validation.ts .env.example
git commit -m "chore: rewrite env validation for Convex + Clerk + Upstash stack"
```

---

## Final Verification

### Task 29: Full Build + Type Check

**Step 1: TypeScript check**

```bash
npx tsc --noEmit --pretty
```

Expected: Zero errors (or only pre-existing non-migration errors).

**Step 2: Build**

```bash
npm run build
```

Expected: Successful Next.js build.

**Step 3: Verify no orphan imports**

```bash
grep -r "prisma\|supabase\|dual-database\|sync-queue\|data-source" --include="*.ts" --include="*.tsx" -l | grep -v node_modules | grep -v __tests__ | grep -v docs/ | grep -v scripts/migrate
```

Expected: Zero results (except `lib/temporal.ts` which references Prisma in comments — clean those up).

**Step 4: Verify deleted files don't exist**

```bash
ls lib/dual-database.ts lib/sync-queue.ts lib/data-source.ts lib/supabase.ts lib/generated/prisma/ prisma/ supabase/ Dockerfile.dev docker-compose.yml docker-compose.infra.yml 2>&1
```

Expected: All "No such file or directory".

**Step 5: Final commit**

```bash
git add -A
git commit -m "chore: final cleanup — full Convex cutover verified"
```

---

## Task Dependency Graph

```
Phase 1 (Gap Fixes)          Phase 2 (Storage)         Phase 3 (Redis)
T1 Nurture ──┐               T6 Storage infra ──┐      T13 OTP ──┐
T2 Customers ├── T5 Wire ──> T7 Contact attach   │     T14 Tokens │
T3 Expenses  │   all routes  T8 Job CVs          ├──>  T15 Rate   ├──> Phase 4
T4 Blog      ┘               T9 Blog images      │     T16 Cache  │    (Deletion)
                              T10 Avatars         │     T17 Health │
                              T11 Attachments     │     T18 BullMQ ┘
                              T12 Migration script┘

Phase 4 (Scaffolding)         Phase 5 (Docker)
T19 Delete dual-db ──┐        T24 Delete Docker files ──┐
T20 Delete datasrc   │        T25 Update prod Dockerfile │
T21 Delete Prisma    ├──>     T26 Update npm scripts     ├──> T29 Final verify
T22 Delete Supabase  │        T27 Update CI workflows    │
T23 Test cleanup     ┘        T28 Rewrite env validation ┘
```

Phases 1, 2, and 3 can run partially in parallel (storage migration is independent of Redis migration). Phase 4 requires Phase 1+2+3 complete. Phase 5 requires Phase 4.
