---
phase: 04-shared-lists-live-sync
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - apps/pwa/package.json
  - apps/api/package.json
  - apps/api/src/db/schema.ts
  - apps/api/src/db/migrations/0002_lists_schema.sql
  - apps/api/test/setup.ts
  - apps/api/vitest.config.ts
  - apps/api/tests/routes/lists.test.ts
  - apps/api/tests/lib/listEmitter.test.ts
  - apps/pwa/src/hooks/useListSSE.test.ts
  - apps/pwa/src/routes/ListDetail.test.tsx
  - apps/pwa/src/App.tsx
  - apps/pwa/src/components/BottomTabBar.tsx
  - apps/pwa/src/routes/ListsIndex.tsx
  - apps/pwa/src/store/listsStore.ts
autonomous: false
requirements: [LIST-01, LIST-02, LIST-03, LIST-04]
user_setup: []

must_haves:
  truths:
    - "User can tap a 'Lists' tab in a bottom tab bar (D-16) and land on a /lists route served by react-router (D-17)"
    - 'The /lists route renders an empty state when no lists exist'
    - 'The new lists/list_items/list_shares tables exist in MariaDB after migration'
    - 'API test harness runs and the Phase 4 RED test stubs execute (failing, not erroring on import)'
  artifacts:
    - path: 'apps/api/src/db/schema.ts'
      provides: 'lists, listShares, listItems Drizzle tables'
      contains: 'export const lists'
    - path: 'apps/api/src/db/migrations/0002_lists_schema.sql'
      provides: 'additive CREATE TABLE migration for the three list tables'
      contains: 'CREATE TABLE'
    - path: 'apps/pwa/src/components/BottomTabBar.tsx'
      provides: 'Calendar | Lists bottom tab navigation'
      min_lines: 25
    - path: 'apps/pwa/src/routes/ListsIndex.tsx'
      provides: 'Lists surface with empty state'
      min_lines: 20
    - path: 'apps/api/tests/routes/lists.test.ts'
      provides: 'RED test stubs for LIST-01/02/03/04 API behavior'
      contains: 'describe'
  key_links:
    - from: 'apps/pwa/src/App.tsx'
      to: '/lists'
      via: 'react-router Route + BottomTabBar NavLink'
      pattern: 'lists'
    - from: 'apps/api/src/db/schema.ts'
      to: 'MariaDB'
      via: 'drizzle-kit generate + migrate'
      pattern: "mysqlTable\\('lists'"
---

<objective>
Establish the Phase 4 foundation as a thin, runnable end-to-end shell: install the four new npm dependencies, add the three list tables to the Drizzle schema and apply them via a generated migration, scaffold the API test harness with the Phase 4 Wave-0 RED test stubs, and add react-router + a bottom tab bar so the user can navigate to a (currently empty) Lists surface.

This is the MVP first slice: after this plan a real user can tap "Lists" and see the Lists surface render (empty state). No list data yet — later slices fill it in. Wave 0 test stubs are created here so every downstream task has an `<automated>` target per 04-VALIDATION.md.

Purpose: De-risk the transport/routing/schema/test plumbing before any list feature is built, and satisfy the [BLOCKING] generate+migrate schema constraint once for all later DB-dependent work.
Output: New deps installed; three tables migrated; API vitest harness + 4 RED stub test files; router + BottomTabBar + ListsIndex empty state; listsStore (UI-only).

## Phase Goal

**As a** household member, **I want to** create and manage shared named lists with real-time co-edit sync, **so that** my partner and I see each other's list edits appear within seconds without refreshing. (This plan delivers the navigable shell; later plans fill in CRUD, reorder, and live sync.)
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md
@.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md
@.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md
@.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md
@.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md
</context>

<tasks>

<task type="checkpoint:human-verify" gate="blocking-human">
  <name>Task 1: Package legitimacy gate for the SUS-flagged react-router</name>
  <files>apps/pwa/package.json</files>
  <read_first>
    - .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Package Legitimacy Audit"
  </read_first>
  <what-built>Nothing yet — this gate precedes the install in Task 2.</what-built>
  <action>
    Per the Package Legitimacy Audit, three packages (@dnd-kit/core, @dnd-kit/sortable, fractional-indexing) are verdict OK and auto-approved. `react-router` is flagged SUS only because version 7.17.0 was published 2026-06-04 (version-recency false positive); the package is the canonical React Router (remix-run, ~12 yrs, 47.5M/wk). Surface this to the operator for a one-time confirm before installing, since legitimacy checkpoints are never auto-approvable.
  </action>
  <how-to-verify>
    1. Open https://www.npmjs.com/package/react-router and confirm publisher is `remix-run`/`react-router` org with multi-year history and ~47M weekly downloads.
    2. Confirm version 7.x is the current major.
  </how-to-verify>
  <acceptance_criteria>
    - Operator types "approved" (or names a pinned version) before Task 2 runs.
  </acceptance_criteria>
  <resume-signal>Type "approved" to proceed with the install, or specify an alternate version.</resume-signal>
</task>

<task type="auto">
  <name>Task 2: Install new dependencies + scaffold API test harness with Wave-0 RED stubs</name>
  <files>apps/pwa/package.json, apps/api/package.json, apps/api/test/setup.ts, apps/api/vitest.config.ts, apps/api/tests/routes/lists.test.ts, apps/api/tests/lib/listEmitter.test.ts, apps/pwa/src/hooks/useListSSE.test.ts, apps/pwa/src/routes/ListDetail.test.tsx</files>
  <read_first>
    - apps/api/vitest.config.ts
    - apps/api/src/db/client.ts
    - apps/pwa/src/api/client.test.ts (existing PWA test convention)
    - .planning/phases/04-shared-lists-live-sync/04-VALIDATION.md §"Wave 0 Requirements" and §"Per-Task Verification Map"
    - .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Standard Stack" → "New Dependencies"
  </read_first>
  <action>
    Install PWA deps: react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing via `pnpm --filter @familysync/pwa add`. Install API dep fractional-indexing via `pnpm --filter @familysync/api add` (needed server-side for rank generation). Do NOT install ioredis — fan-out is in-memory EventEmitter per RESEARCH discretion (justified in Plan 02).

    Scaffold the API test harness: the API currently has zero test files. Create `apps/api/test/setup.ts` and reference it from `apps/api/vitest.config.ts` (`test.setupFiles`). The setup file must establish how DB-backed route tests connect — point at the local MariaDB via the existing `apps/api/src/db/client.ts` pool (DB_HOST/DB_NAME from env), and provide a per-test cleanup (truncate lists/list_items/list_shares between tests). Pure-logic tests (listEmitter, fractional rank) do NOT need the DB.

    Create the four Wave-0 RED stub test files listed in 04-VALIDATION.md, each with `describe`/`it.todo` or `it(... )` blocks that compile and FAIL (red) rather than error on import — they import the not-yet-existing modules behind a guard or use `it.todo` placeholders that downstream plans convert to real assertions:
    - apps/api/tests/routes/lists.test.ts — LIST-01/02/03/04 API behavior stubs
    - apps/api/tests/lib/listEmitter.test.ts — scoped fan-out correctness (D-04) stubs
    - apps/pwa/src/hooks/useListSSE.test.ts — D-11 bounded backoff (mock EventSource) stubs
    - apps/pwa/src/routes/ListDetail.test.tsx — D-07 optimistic update + rollback stubs

  </action>
  <verify>
    <automated>pnpm --filter @familysync/api test 2>&1 | grep -Eiq 'todo|fail|no tests|passed' && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts src/routes/ListDetail.test.tsx 2>&1 | grep -Eiq 'todo|fail|passed'</automated>
  </verify>
  <acceptance_criteria>
    - `react-router`, `@dnd-kit/core`, `@dnd-kit/sortable`, `fractional-indexing` appear in apps/pwa/package.json dependencies.
    - `fractional-indexing` appears in apps/api/package.json dependencies.
    - `ioredis` is NOT added to either package.json.
    - `apps/api/test/setup.ts` exists and is referenced by `setupFiles` in apps/api/vitest.config.ts.
    - All four Wave-0 test files exist and run (todo/red), not import-error.
  </acceptance_criteria>
  <done>New deps installed (no ioredis), API test harness runs, four RED/todo stub files present and executing.</done>
</task>

<task type="auto">
  <name>Task 3: Add list tables to schema and apply via generate+migrate [BLOCKING]</name>
  <files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_lists_schema.sql</files>
  <read_first>
    - apps/api/src/db/schema.ts (full file — table conventions)
    - apps/api/src/db/migrations/0001_calendars_user_url_unique.sql (prior migration shape)
    - apps/api/drizzle.config.ts
    - .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Database Schema Design"
    - .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/db/schema.ts"
    - $HOME/.claude/projects/-home-luc-Projects-familysync/memory/drizzle-mariadb-push-unsafe.md
  </read_first>
  <action>
    Append three tables to apps/api/src/db/schema.ts following the exact conventions in 04-RESEARCH §Database Schema Design and the analog patterns in 04-PATTERNS:
    - `lists`: int autoincrement PK, `ownerId` int('owner_id') references users.id onDelete cascade notNull, `name` varchar(255) notNull, `isShared` boolean('is_shared') default true notNull (D-01), `createdAt` timestamp defaultNow notNull, `updatedAt` timestamp defaultNow onUpdateNow; index idx_lists_owner_id on ownerId.
    - `listShares` (D-02, member-count-agnostic join table): int PK, `listId` int('list_id') references lists.id onDelete cascade notNull, `userId` int('user_id') references users.id onDelete cascade notNull, `createdAt` timestamp defaultNow notNull; unique('uniq_list_share') on (listId, userId), index idx_list_shares_user_id on userId.
    - `listItems`: int PK, `listId` int('list_id') references lists.id onDelete cascade notNull, `text` varchar(500) notNull, `checked` boolean default false notNull, `rank` varchar(255) notNull (D-13 fractional-indexing string), `createdAt`, `updatedAt`; index idx_list_items_list_id_rank on (listId, rank), index idx_list_items_list_id_checked on (listId, checked).

    Then generate and apply the migration. This is [BLOCKING]: run `pnpm --filter @familysync/api db:generate` to produce `apps/api/src/db/migrations/0002_lists_schema.sql` (+ journal/snapshot). REVIEW the generated SQL — it MUST be additive (CREATE TABLE only, NO DROP/TRUNCATE of existing tables). Then run `pnpm --filter @familysync/api db:migrate` to apply. NEVER run `db:push` / `drizzle-kit push` — it emits a false destructive diff on populated MariaDB (hard project constraint). Build/type checks pass without the live migration, so this task is mandatory and must complete before any DB-dependent verification in later plans.

  </action>
  <verify>
    <automated>grep -q "mysqlTable('lists'" apps/api/src/db/schema.ts && grep -q "mysqlTable('list_shares'" apps/api/src/db/schema.ts && grep -q "mysqlTable('list_items'" apps/api/src/db/schema.ts && test -f apps/api/src/db/migrations/0002_lists_schema.sql && grep -iq 'CREATE TABLE' apps/api/src/db/migrations/0002_lists_schema.sql && ! grep -iE 'DROP TABLE `?(users|calendars|calendar_events|calendar_outbox|member_credentials)' apps/api/src/db/migrations/0002_lists_schema.sql && pnpm --filter @familysync/api typecheck</automated>
  </verify>
  <acceptance_criteria>
    - Three tables present in schema.ts with the column/index/FK shapes above.
    - 0002_lists_schema.sql exists, contains CREATE TABLE for lists/list_items/list_shares, and contains NO DROP/TRUNCATE of any pre-existing table.
    - `db:migrate` applied successfully (migration recorded in drizzle journal).
    - `pnpm --filter @familysync/api typecheck` passes.
  </acceptance_criteria>
  <done>list/list_items/list_shares tables exist in MariaDB via additive generate+migrate; typecheck green; no push used.</done>
</task>

<task type="auto">
  <name>Task 4: Add react-router + BottomTabBar + empty ListsIndex shell</name>
  <files>apps/pwa/src/App.tsx, apps/pwa/src/components/BottomTabBar.tsx, apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/store/listsStore.ts</files>
  <read_first>
    - apps/pwa/src/App.tsx (current one-liner)
    - apps/pwa/src/components/CalendarShell.tsx (state-branch + data-fetch conventions)
    - apps/pwa/src/components/AppNav.tsx (nav/active-state + CSS token conventions)
    - apps/pwa/src/store/calendarStore.ts (Zustand shape convention)
    - apps/pwa/vite.config.ts (confirm navigateFallback already covers /lists/*)
    - .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"Layout: App Shell Changes", §"BottomTabBar", §"ListsIndex", §"ListsEmptyState"
    - .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/pwa/src/App.tsx", §"BottomTabBar.tsx", §"ListsIndex.tsx", §"listsStore.ts"
  </read_first>
  <action>
    Transform App.tsx into a BrowserRouter shell (react-router declarative mode, NO data router/loaders): routes `/` → Navigate replace to `/calendar`, `/calendar` → CalendarShell, `/lists` → ListsIndex, `/lists/:listId` → ListDetail. ListDetail does not exist yet — for this plan render a temporary placeholder route element (a stub component that says the list view is coming) so the route resolves; Plan 04 replaces it. Render BottomTabBar as a sibling of `<Routes>`.

    Create BottomTabBar.tsx: fixed-bottom 56px + env(safe-area-inset-bottom), background var(--color-surface-dim), border-top var(--color-border), two equal NavLink tabs (CalendarDays→/calendar, List→/lists) with isActive callback applying accent var(--color-member-0) to icon+label and a 2px active indicator; inactive var(--color-text-muted); 13px label; ≥44px touch target; z-index 200. On desktop (≥768px) the existing AppNav sidebar remains; per UI-SPEC add a "Lists" NavLink there too (sidebar) — do this without breaking the existing AppNav signature.

    Create ListsIndex.tsx: full-height scrollable column, "Lists" heading, useQuery(['lists'], fetchLists) where fetchLists is imported from a minimal listsClient (create only the fetchLists function + List type here if listsClient does not yet exist; Plan 03 expands it). Render ListsEmptyState ("No lists yet" / "Tap + to create your first shared list…") when there are zero lists; render a placeholder card stack otherwise. Wire isLoading/isError/success branches mirroring CalendarShell. Include the "+ New List" FAB affordance (non-functional placeholder is acceptable here; Plan 03 wires CreateListSheet).

    Create listsStore.ts (Zustand, UI-only): activeTab and createListSheetOpen state with setters, following calendarStore conventions (no persist, no immer).

    Confirm vite.config.ts navigateFallback ('/index.html') + denylist already cover SPA deep-links to /lists/* (it does per Phase 3 config) — if a denylist entry would block /lists, fix it; otherwise leave unchanged and note in SUMMARY.

  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa exec vitest run src/components/CalendarShell.test.tsx 2>&1 | grep -Eiq 'passed' && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "BrowserRouter" apps/pwa/src/App.tsx && grep -q "to=\"/lists\"" apps/pwa/src/components/BottomTabBar.tsx</automated>
  </verify>
  <acceptance_criteria>
    - App.tsx wraps the app in BrowserRouter with /calendar, /lists, /lists/:listId routes; existing CalendarShell still mounts at /calendar.
    - BottomTabBar renders Calendar and Lists NavLinks with active-state accent and ≥44px targets.
    - ListsIndex renders the empty state copy from UI-SPEC when no lists exist.
    - listsStore exports activeTab/createListSheetOpen with setters (no server data).
    - PWA typecheck passes; existing CalendarShell test still green.
    - Browser check (project convention): `playwright-cli` navigates to /lists and observes the "No lists yet" empty state and the bottom tab bar with an active "Lists" tab. Record the observation in SUMMARY.
  </acceptance_criteria>
  <done>User can tap the Lists tab and land on the empty Lists surface; calendar still works; router + tab bar in place.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                          | Description                                              |
| --------------------------------- | -------------------------------------------------------- |
| browser → /api/\*                 | All list/SSE requests cross here; untrusted client input |
| API → MariaDB                     | Drizzle parameterized queries only                       |
| drizzle-kit → MariaDB (migration) | DDL applied to a populated production-shaped DB          |

## STRIDE Threat Register

| Threat ID | Category               | Component                                                     | Disposition | Mitigation Plan                                                                                                                                         |
| --------- | ---------------------- | ------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-04-01   | Tampering              | drizzle-kit push truncating populated tables                  | mitigate    | generate+migrate ONLY; verify 0002 SQL has no DROP/TRUNCATE of existing tables before applying (Task 3 gate)                                            |
| T-04-02   | Information Disclosure | scoped-fan-out leak (D-04) — foundational                     | mitigate    | Schema models access via list_shares join table + owner_id (this plan); enforcement lands in Plans 02/03/06; negative test seeded in lists.test.ts here |
| T-04-SC   | Tampering              | npm installs (react-router SUS, dnd-kit, fractional-indexing) | mitigate    | Legitimacy audit in RESEARCH; blocking human checkpoint (Task 1) for the SUS react-router before install                                                |

</threat_model>

<verification>
- `pnpm --filter @familysync/api typecheck` and `pnpm --filter @familysync/pwa exec tsc --noEmit` both pass.
- `pnpm --filter @familysync/api test` runs (Wave-0 stubs red/todo, not erroring).
- 0002_lists_schema.sql is additive; migration applied; three tables queryable.
- `playwright-cli` confirms /lists renders the empty state with the bottom tab bar.
</verification>

<success_criteria>

- New deps installed (no ioredis); API test harness operational.
- Three list tables migrated additively (no push).
- Router + BottomTabBar live; Lists tab navigates to an empty Lists surface.
- Four Wave-0 RED stub test files exist and execute.
  </success_criteria>

<artifacts_produced>
**Symbols/files this plan creates (exclude from drift verification — they are new):**

- Tables: `lists`, `list_shares`, `list_items` (apps/api/src/db/schema.ts)
- Migration: `apps/api/src/db/migrations/0002_lists_schema.sql` (+ journal/snapshot)
- API test harness: `apps/api/test/setup.ts`; setupFiles wiring in `apps/api/vitest.config.ts`
- RED stub tests: `apps/api/tests/routes/lists.test.ts`, `apps/api/tests/lib/listEmitter.test.ts`, `apps/pwa/src/hooks/useListSSE.test.ts`, `apps/pwa/src/routes/ListDetail.test.tsx`
- Components: `BottomTabBar` (apps/pwa/src/components/BottomTabBar.tsx), `ListsIndex` (apps/pwa/src/routes/ListsIndex.tsx), temporary ListDetail placeholder route element
- Store: `useListsStore` (apps/pwa/src/store/listsStore.ts) with activeTab/createListSheetOpen
- App.tsx now exports a BrowserRouter-wrapped App + AppShell
- (Possibly) initial `apps/pwa/src/api/listsClient.ts` with `fetchLists` + `List` type
  </artifacts_produced>

<output>
Create `.planning/phases/04-shared-lists-live-sync/04-01-SUMMARY.md` when done.
</output>
