Files
familysync/.planning/phases/04-shared-lists-live-sync/04-VERIFICATION.md
T
Lucas Berger fa71cf1a30 docs(04): re-verify phase 04 after gap-closure — 4/4 verified, 0 threats open
LIST-03 collation gap closed, T-04-05 + T-04-08 closed by plan 04-07.
Phase 04 sign-off complete.
2026-06-09 14:32:54 -04:00

17 KiB

phase, verified, status, score, overrides_applied, re_verification
phase verified status score overrides_applied re_verification
04-shared-lists-live-sync 2026-06-09T18:30:00Z passed 4/4 must-haves verified 0
previous_status previous_score gaps_closed gaps_remaining regressions
gaps_found 3/4
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

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.sql0 (confirmed)
  • Schema: grep -c 'utf8mb4_bin' schema.ts4 (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
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 subscribeListEventspublishListEvent 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)