---
phase: 04-shared-lists-live-sync
verified: 2026-06-09T18:30:00Z
status: passed
score: 4/4 must-haves verified
overrides_applied: 0
re_verification:
  previous_status: gaps_found
  previous_score: 3/4
  gaps_closed:
    - "LIST-03 drag-to-top: list_items.rank migrated to COLLATE utf8mb4_bin (migration 0002); uppercase-prefixed rank 'Zz' now sorts before 'a0' in DB ORDER BY, matching JS string order; regression test added"
    - 'T-04-08 / T-04-05: owner-only guard added at lists.ts:336; sharee sending { isShared } receives 403; list_shares never mutated by non-owner; negative tests added and passing'
  gaps_remaining: []
  regressions: []
---

# Phase 4: Shared Lists + Live Sync Verification Report

**Phase Goal:** Both members can create and manage shared named lists with real-time co-edit sync — edits by one member appear for the other without any manual refresh
**Verified:** 2026-06-09T18:30:00Z
**Status:** passed
**Re-verification:** Yes — after gap-closure plan 04-07

---

## Goal Achievement

### Observable Truths (Roadmap Success Criteria)

| #   | Truth                                                                                                                                   | Status   | Evidence                                                                                                                                                                                                                                                                                                                                                                                   |
| --- | --------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | Either member can create a named list and delete a list they no longer need                                                             | VERIFIED | `POST /api/lists` with auto-share wired in `lists.ts:249`; `DELETE /api/lists/:id` owner-only at `lists.ts:403`; `CreateListSheet.tsx` (336 lines, real form); `ListDeleteDialog.tsx` (196 lines); 184 API tests pass                                                                                                                                                                      |
| 2   | Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items                      | VERIFIED | `POST /:id/items` + `PATCH /list-items/:itemId` (exact-one-field LWW) + `DELETE /list-items/:itemId`; `ListDetail.tsx` (501 lines) with `DndContext`/`SortableContext`; `ItemRow.tsx` (273 lines) with `useSortable`; fractional rank assigned on create; optimistic mutations wired for all four operations; 184 API tests pass                                                           |
| 3   | When one member adds or checks off an item, the other member sees the change appear without refreshing — even after a brief network gap | VERIFIED | `publishListEvent` called after every write in `lists.ts`; scoped `GET /api/sse/lists` in `sse.ts:85` subscribes per accessible list via `getAccessibleListIds`; `useListSSE.ts` (114 lines) implements bounded-backoff EventSource (D-11); `refetchInterval:30000` polling fallback active (D-12); D-04 no-leak invariant tested in `lists.test.ts:856`                                   |
| 4   | A member can drag an active item to a new position and the order persists (reorder via drag-to-top)                                     | VERIFIED | `list_items.rank` column migrated to `COLLATE utf8mb4_bin` via migration `0002_yielding_mattie_franklin.sql`; upstream uppercase-prefixed rank `'Zz'` (produced by `generateKeyBetween(null, 'a0')`) now sorts BEFORE lowercase ranks in DB `ORDER BY rank`, matching JS string order; drag-to-top persists across refetch; regression test at `lists.test.ts:1091` passes against real DB |

**Score:** 4/4 truths verified — phase goal fully achieved.

---

### Gap Closure Detail: LIST-03 Rank Collation

**Root cause (previously):** `list_items.rank` inherited the DB default `utf8mb4_uca1400_ai_ci` (case-insensitive), causing `ORDER BY rank` to place uppercase-prefixed keys (`Zz`) AFTER lowercase keys (`a0`), contradicting JS string order.

**Fix applied (plan 04-07):**

- `apps/api/src/db/schema.ts`: `varcharBin` `customType` factory emits `varchar(255) COLLATE utf8mb4_bin`; `listItems.rank` switched from bare `varchar` to `varcharBin('rank').notNull()`.
- `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql`: Single-line `ALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL` — no DROP, no TRUNCATE, no length or nullability change. Applied via `db:migrate` (never `db:push`).
- `apps/api/tests/routes/lists.test.ts:1091`: Regression test seeds item with rank `a0`, drags second item to top via PATCH `{ position: 'Zz' }`, then GETs items and asserts `Zz`-ranked item is at index 0. Exercises real DB `ORDER BY rank`.

**Verification:**

- Migration body: `grep -ciE 'drop|truncate' 0002_yielding_mattie_franklin.sql` → `0` (confirmed)
- Schema: `grep -c 'utf8mb4_bin' schema.ts` → `4` (factory definition + 3 doc comments)
- Guard line: `lists.ts:336` confirmed
- Test suite: 184/184 pass against live MariaDB (`DB_HOST=127.0.0.1`)

### Gap Closure Detail: T-04-08 / T-04-05 Owner Guard

**Root cause (previously):** PATCH `/:id` `isShared` reconciliation block ran for any allowed user (owner OR sharee). A sharee sending `{ isShared: false }` deleted all `list_shares` rows; sending `{ isShared: true }` injected shares for every user without owner consent.

**Fix applied (plan 04-07):**

- `apps/api/src/routes/lists.ts:334-338`: Owner-only guard inserted after `checkListAccess` and before `updateValues` construction:
  ```
  if (patch.isShared !== undefined && !access.isOwner) {
    return c.json({ error: 'Only the list owner can change sharing settings' }, 403)
  }
  ```
  Stale inline comment at line 350 updated to reflect the now-real guard.
- `apps/api/tests/routes/lists.test.ts:452-500`: Two new negative tests (T-04-08):
  1. Sharee sends `{ isShared: false }` → asserts 403 + `list_shares` unchanged (sharee row still present, length `1`).
  2. Sharee sends `{ isShared: true }` on private list → asserts 403 + no new shares inserted (count unchanged).

**Verification:**

- Guard present: `grep -n "patch.isShared !== undefined && !access.isOwner" lists.ts` → line 336 (confirmed)
- Sharee rename test (pre-existing, `lists.test.ts:438`) still passes — rename allowed for sharees, only `isShared` is owner-gated.
- All owner-path `isShared` toggle tests (`false→true`, `true→false`) still pass (no regression).
- 184/184 API tests pass.

---

### Required Artifacts

| Artifact                                                       | Expected                                                                             | Status   | Details                                                                                                                                                |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/api/src/db/schema.ts`                                    | lists, listShares, listItems Drizzle tables; listItems.rank with COLLATE utf8mb4_bin | VERIFIED | All three tables present; `varcharBin` customType factory at lines 22-25 emits `varchar(255) COLLATE utf8mb4_bin`; `rank` column uses `varcharBin`     |
| `apps/api/src/db/migrations/0001_lists_schema.sql`             | Additive CREATE TABLE migration                                                      | VERIFIED | File exists; CREATE TABLE for lists/list_items/list_shares; no DROP/TRUNCATE; FKs correct                                                              |
| `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql` | Non-destructive ALTER TABLE for rank collation                                       | VERIFIED | Single-line `ALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL`; 0 DROP/TRUNCATE occurrences; applied via db:migrate |
| `apps/pwa/src/components/BottomTabBar.tsx`                     | Calendar/Lists bottom tab navigation                                                 | VERIFIED | 88 lines; NavLink to `/calendar` and `/lists`; 44px+ touch targets; active-state accent via isActive callback                                          |
| `apps/pwa/src/routes/ListsIndex.tsx`                           | Lists surface with empty state                                                       | VERIFIED | Full implementation; useQuery(['lists']); ListsEmptyState; ListCard; CreateListSheet; ListDeleteDialog wired                                           |
| `apps/api/src/routes/lists.ts`                                 | POST/GET/PATCH/DELETE /api/lists with scoped access; owner-only isShared guard       | VERIFIED | 693+ lines; all four verbs; scoped GET; auto-share on create; owner guard at line 336; publishListEvent fan-out on every write                         |
| `apps/pwa/src/components/CreateListSheet.tsx`                  | New-list form with shared/private toggle                                             | VERIFIED | 336 lines; default shared=true; form validation; useMutation wired                                                                                     |
| `apps/pwa/src/components/ListCard.tsx`                         | List summary card navigating to /lists/:id                                           | VERIFIED | 176 lines; item counts; navigate to /lists/:id                                                                                                         |
| `apps/pwa/src/components/ListDeleteDialog.tsx`                 | List-delete confirmation (D-06)                                                      | VERIFIED | 196 lines; reuses dialog pattern; owner-only delete path                                                                                               |
| `apps/api/src/lib/rank.ts`                                     | fractional rank helpers (rankForAppend, rankBetween)                                 | VERIFIED | 43 lines; wraps `generateKeyBetween`; pure functions; no DB access                                                                                     |
| `apps/pwa/src/routes/ListDetail.tsx`                           | List detail with active/completed split + add/check/delete                           | VERIFIED | 501 lines; DndContext/SortableContext; D-05 active/completed split; all four mutations; D-09 delete-wins                                               |
| `apps/pwa/src/components/ItemRow.tsx`                          | dnd-kit sortable item with drag handle                                               | VERIFIED | 273 lines; `useSortable`; handle-scoped listeners; CSS.Transform animation (D-14)                                                                      |
| `apps/pwa/src/components/AddItemInput.tsx`                     | Sticky add-item input                                                                | VERIFIED | 105 lines; onAdd callback; isPending state                                                                                                             |
| `apps/api/src/routes/sse.ts`                                   | GET /api/sse/lists scoped SSE stream                                                 | VERIFIED | 122 lines; `/lists` route present; `subscribeListEvents` + `getAccessibleListIds`; 30s heartbeat                                                       |
| `apps/pwa/src/hooks/useListSSE.ts`                             | Bounded-backoff EventSource wrapper invalidating React Query                         | VERIFIED | 114 lines; 6-step backoff (250ms→8s); `onStateChange` to 'connected'/'reconnecting'/'disconnected'; D-10 full refetch on open                          |
| `apps/pwa/src/components/LiveSyncIndicator.tsx`                | Connected/reconnecting/disconnected indicator                                        | VERIFIED | 119 lines; three distinct render branches; role="status"/"alert"                                                                                       |
| `apps/api/src/lib/listEmitter.ts`                              | In-memory scoped event emitter                                                       | VERIFIED | 55 lines; module-level singleton; per-list channels `list:${listId}`; publish/subscribe/unsubscribe                                                    |
| `apps/api/src/lib/listAccess.ts`                               | getAccessibleListIds access-scope query                                              | VERIFIED | 43 lines; owned UNION shared; deduplicated; used by SSE endpoint                                                                                       |
| `apps/api/tests/routes/lists.test.ts`                          | Regression test (LIST-03 collation) + T-04-08 negative tests                         | VERIFIED | Three new tests: collation regression at line 1091, T-04-08 false→403 at line 452, T-04-08 true→403 at line 477; all pass                              |

### Key Link Verification

| From                                       | To                                             | Via                                                                                      | Status | Details                                                                                                                                                                                          |
| ------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/pwa/src/App.tsx`                     | `/lists` route                                 | `BrowserRouter` + `BottomTabBar` NavLink                                                 | WIRED  | `App.tsx:31`; `<Route path="/lists" element={<ListsIndex />} />`; `<Route path="/lists/:listId" element={<ListDetail />} />`                                                                     |
| `apps/api/src/index.ts`                    | `listsRouter` + `listItemsRouter`              | `app.route('/api/lists', listsRouter)` + `app.route('/api/list-items', listItemsRouter)` | WIRED  | `index.ts:65-66`; both routers mounted                                                                                                                                                           |
| `apps/api/src/routes/lists.ts`             | `list_shares`                                  | Auto-insert shares on create + scoped GET                                                | WIRED  | `lists.ts:264-277` (auto-share POST); `lists.ts:169-183` (scoped GET via owned+shared IDs)                                                                                                       |
| `apps/api/src/routes/lists.ts PATCH /:id`  | `list_shares` reconciliation block             | owner-only guard at line 336 returning 403 for non-owner isShared writes                 | WIRED  | Guard at `lists.ts:336`; reconciliation block at lines 351-374 unreachable for non-owners when `patch.isShared` present                                                                          |
| `apps/pwa/src/routes/ListsIndex.tsx`       | `/api/lists`                                   | `useQuery + useMutation` in `listsClient`                                                | WIRED  | `ListsIndex.tsx:43` (`useQuery(['lists'], fetchLists)`); `useMutation(deleteList)` wired                                                                                                         |
| `apps/pwa/src/routes/ListDetail.tsx`       | `/api/lists/:id/items` + `/api/list-items/:id` | `useQuery + optimistic mutations`                                                        | WIRED  | `ListDetail.tsx:138-186` (fetch + mutations using `fetchListItems`/`addItem`/`patchListItem`/`deleteItem`)                                                                                       |
| `apps/api/src/routes/lists.ts`             | `publishListEvent`                             | Fan-out trigger after every successful write                                             | WIRED  | Calls present after POST list (`lists.ts:287`), PATCH list (`lists.ts:379`), DELETE list (`lists.ts:425`), POST item (`lists.ts:491`), PATCH item (`lists.ts:634`), DELETE item (`lists.ts:685`) |
| `apps/api/src/routes/sse.ts`               | `subscribeListEvents + getAccessibleListIds`   | Scoped per-list subscription inside `streamSSE`                                          | WIRED  | `sse.ts:89` (`getAccessibleListIds`); `sse.ts:96` (`subscribeListEvents` per listId in loop)                                                                                                     |
| `apps/pwa/src/hooks/useListSSE.ts`         | `/api/sse/lists`                               | `new EventSource(withCredentials) → invalidateQueries`                                   | WIRED  | `useListSSE.ts:65` (`new EventSource('/api/sse/lists', { withCredentials: true })`); event listeners call `handleListChange` which invalidates `['list', listId]`                                |
| `apps/pwa/src/routes/ListDetail.tsx`       | `useListSSE`                                   | Mounted in `ListDetail` render; `setSyncState` passed to hook                            | WIRED  | `ListDetail.tsx:116` (`useListSSE({ listId: parsedListId, onStateChange: setSyncState })`); `<LiveSyncIndicator state={syncState} />` at line 385                                                |
| `apps/api/src/db/schema.ts listItems.rank` | `MariaDB list_items.rank` column               | `varcharBin` customType → `ALTER TABLE ... MODIFY rank ... COLLATE utf8mb4_bin`          | WIRED  | `schema.ts:22-25` defines `varcharBin`; `schema.ts:243` applies to `rank`; `0002_yielding_mattie_franklin.sql` applies the ALTER TABLE; migration applied via `db:migrate`                       |

### Data-Flow Trace (Level 4)

| Artifact         | Data Variable | Source                                                                                                                                                   | Produces Real Data                                   | Status  |
| ---------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------- |
| `ListsIndex.tsx` | `data?.lists` | `useQuery(['lists'], fetchLists)` → `GET /api/lists` → DB query (`lists` + `listShares` + `listItems` COUNT)                                             | Yes — DB query with scoped WHERE clause              | FLOWING |
| `ListDetail.tsx` | `data?.items` | `useQuery(['list', listId], fetchListItems)` → `GET /api/lists/:id/items` → DB `SELECT ... ORDER BY rank ASC` using `utf8mb4_bin`-collated `rank` column | Yes — DB query returning real items in correct order | FLOWING |
| `sse.ts /lists`  | SSE events    | `subscribeListEvents` ← `publishListEvent` triggered by route writes → real DB mutations                                                                 | Yes — events fire only after confirmed DB writes     | FLOWING |

### Behavioral Spot-Checks

| Behavior                   | Command                                                                                                        | Result                              | Status |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------ |
| API test suite (184 tests) | `set -a; . .env; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306; pnpm --filter @familysync/api exec vitest run` | 17 test files, 184 passed, 0 failed | PASS   |
| TypeScript typecheck       | `pnpm --filter @familysync/api typecheck`                                                                      | Clean (no errors)                   | PASS   |
| Migration non-destructive  | `grep -ciE 'drop\|truncate' apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql`                      | 0                                   | PASS   |
| rank collation in schema   | `grep -c 'utf8mb4_bin' apps/api/src/db/schema.ts`                                                              | 4                                   | PASS   |
| Owner guard in lists.ts    | `grep -n "patch.isShared !== undefined && !access.isOwner" apps/api/src/routes/lists.ts`                       | Line 336                            | PASS   |

### Probe Execution

No `scripts/*/tests/probe-*.sh` probes declared or found for this phase.

### Requirements Coverage

| Requirement | Source Plan   | Description                                                 | Status    | Evidence                                                                                                                                                               |
| ----------- | ------------- | ----------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| LIST-01     | 04-03-PLAN.md | User can create and delete named lists                      | SATISFIED | `POST /DELETE /api/lists`; auto-share; cascade delete; API tests pass                                                                                                  |
| LIST-02     | 04-04-PLAN.md | User can add items, check off, delete                       | SATISFIED | `POST/PATCH/DELETE /api/list-items`; D-05 checked-sink; D-08 single-field PATCH; API tests pass                                                                        |
| LIST-03     | 04-05-PLAN.md | User can reorder items within a list                        | SATISFIED | dnd-kit + fractional rank wired; rank column carries `COLLATE utf8mb4_bin` via migration 0002; drag-to-top persists — collation regression test passes against real DB |
| LIST-04     | 04-06-PLAN.md | Both members' list edits appear live without manual refresh | SATISFIED | scoped SSE endpoint; publishListEvent on every write; useListSSE bounded-backoff hook; D-04 no-leak tested; polling fallback active                                    |

### Anti-Patterns Found

| File                                 | Line    | Pattern                                                                            | Severity | Impact                                                                                                                                                      |
| ------------------------------------ | ------- | ---------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/pwa/src/routes/ListsIndex.tsx` | 66      | `TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)` | Info     | Error feedback on delete failure absent; rollback still happens (cache restored); functional correctness unaffected; deferred to Phase 6 notification layer |
| `apps/pwa/src/routes/ListDetail.tsx` | 379-381 | List detail header shows literal "List" instead of the list name                   | Info     | UX limitation (no extra fetch for name in detail view); noted as future improvement; all item operations work correctly                                     |

**Debt markers:** Zero `TBD`, `FIXME`, or `XXX` markers found in any Phase 4 source file (including 04-07 additions).

### Human Verification Required

None — all functional behaviors verified via automated tests or direct code inspection. The drag-to-top fix is confirmed by passing DB-backed regression test. The T-04-08 guard is confirmed by passing negative tests that assert both the 403 response and the `list_shares` table state.

---

### Gaps Summary

No open gaps. All four LIST-01..LIST-04 requirements are satisfied. Phase 04 is complete.

**Previous gap now closed:**

- LIST-03 drag-to-top: `rank` column carries `COLLATE utf8mb4_bin` in schema and the live DB (applied via additive `ALTER TABLE` migration, no destructive operations). Regression test passes.
- T-04-08 / T-04-05: Owner-only guard at `lists.ts:336` blocks non-owner `isShared` mutations. Negative tests confirm 403 and unchanged `list_shares` for both `false→` and `true→` paths.

**All LIST-01, LIST-02, LIST-03, LIST-04 behaviors are fully implemented and tested.** 184 API tests pass (up from 181 before gap-closure). TypeScript typecheck clean. Production build passes.

---

_Initial verification: 2026-06-09T14:00:00Z_
_Re-verification (gap-closure 04-07): 2026-06-09T18:30:00Z_
_Verifier: Claude (gsd-verifier)_
