---
phase: 05-web-push-notifications
plan: 05
type: execute
wave: 4
depends_on: [05-02, 05-03, 05-04]
files_modified:
  - apps/api/src/lib/listChangeDispatcher.ts
  - apps/api/src/routes/lists.ts
  - apps/api/tests/routes/lists.test.ts
  - apps/api/tests/lib/listChangeDispatcher.test.ts
autonomous: true
requirements: [NOTIF-02]

must_haves:
  truths:
    - 'When member A adds/checks-off/deletes/renames an item or list, the OTHER member receives a coalesced push naming the actor + list + change count (NOTIF-02, D-01/D-02/D-03)'
    - 'Reorder (position) PATCHes do NOT trigger any push (D-01)'
    - 'The actor never receives a push for their own change — fan-out filters userId != actorId (D-03)'
    - 'List-change pushes are scoped: only members who can access the list (owner or list_shares) get the push — never broadcast to all members'
  artifacts:
    - path: 'apps/api/src/lib/listChangeDispatcher.ts'
      provides: 'notifyListChange(listId, actorId) — resolves actor name + list name + accessible subscriptions, calls coalesceListPush'
      exports: ['notifyListChange']
      min_lines: 25
  key_links:
    - from: 'apps/api/src/routes/lists.ts'
      to: 'apps/api/src/lib/listChangeDispatcher.ts'
      via: 'notifyListChange called at each meaningful mutation (not reorder)'
      pattern: 'notifyListChange'
    - from: 'apps/api/src/lib/listChangeDispatcher.ts'
      to: 'apps/api/src/lib/pushCoalescer.ts'
      via: 'coalesceListPush with accessible-subscription dispatch + excludeUserId=actorId'
      pattern: 'coalesceListPush'
---

<objective>
Wire NOTIF-02: a member modifying a shared list pushes a coalesced, generic, actor-attributed notification to the OTHER member. This is the list-change vertical slice on top of the push spine (05-04) and the coalescer (05-03).

Purpose: List edits are the chattiest source; D-01 coalescing + D-02 generic copy + D-03 self-suppression turn a grocery burst into a single clean ping. The dispatch hooks the SAME mutation points as the existing publishListEvent SSE fan-out, scoped to list access (never a broadcast).

Output: listChangeDispatcher.ts (notifyListChange) called from the list/item mutation handlers; reorder excluded; tests prove burst→one push, reorder-silent, self-suppression, and access scoping.
</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
@apps/api/src/routes/lists.ts
@apps/api/src/lib/pushCoalescer.ts
@apps/api/src/lib/pushDispatcher.ts
@apps/api/src/db/schema.ts
@apps/api/tests/routes/lists.test.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out</name>
  <read_first>
    - apps/api/src/routes/lists.ts (checkListAccess lines 123-153; GET access scoping lines 168-183 — owner + list_shares union; resolveUserId)
    - apps/api/src/lib/pushCoalescer.ts (coalesceListPush signature)
    - apps/api/src/lib/pushDispatcher.ts (dispatchPush)
    - apps/api/src/db/schema.ts (lists, listShares, users, pushSubscriptions)
    - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (list-change copy template)
  </read_first>
  <behavior>
    - notifyListChange(listId, actorId): resolves actorName (users.displayName via deriveDisplayName fallback) and listName (lists.name); calls coalesceListPush(listId, actorId, actorName, listName, dispatch). The injected dispatch(payload, excludeUserId) computes the accessible audience = {list owner} ∪ {list_shares.userId} MINUS excludeUserId, loads their push_subscriptions, and calls dispatchPush per subscription.
    - Self-suppression: excludeUserId === actorId → actor's own subscriptions are never sent to.
    - Access scoping: a member with no owner/share relationship to the list is never in the audience.
    - Empty audience (no other accessible members or no subscriptions) → no dispatch, no crash.
    Tests (listChangeDispatcher.test.ts, real DB per lists.test.ts harness): seed two users, a shared list, push_subscriptions for both; call notifyListChange(listId, actorA) thrice within window, advance fake timers → exactly one dispatchPush to userB (mock dispatchPush), body "3 changes", actorA never dispatched to. Seed a third unrelated user with no access → never dispatched.
  </behavior>
  <action>
    Create apps/api/src/lib/listChangeDispatcher.ts exporting notifyListChange(listId, actorId). Reuse the owner + list_shares union access query idiom from lists.ts GET (lines 168-183) to build the audience. Mock dispatchPush in tests (vi.mock) to assert recipients without network. Use vi.useFakeTimers to drive the coalescer window. Log errors with '[listChangeDispatcher]' prefix; one failed send must not abort the loop.
  </action>
  <verify>
    <automated>cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts</automated>
  </verify>
  <acceptance_criteria>
    Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op.
  </acceptance_criteria>
  <done>Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested.</done>
</task>

<task type="auto">
  <name>Task 2: Hook notifyListChange into list/item mutations (reorder excluded)</name>
  <read_first>
    - apps/api/src/routes/lists.ts (every publishListEvent call site: POST /:id/items line 497, PATCH /list-items/:itemId line 640, DELETE /list-items/:itemId line 691, POST / line 287, PATCH /:id line 385, DELETE /:id line 431)
    - apps/api/tests/routes/lists.test.ts (existing harness for the reorder-silent assertion)
    - .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-01 reorder does NOT push)
  </read_first>
  <action>
    In apps/api/src/routes/lists.ts, after each MEANINGFUL mutation's publishListEvent call, add notifyListChange(listId, currentUserId): item added (POST /:id/items), item checked/unchecked or text edited (PATCH /list-items/:itemId — but NOT when the patch was a position change), item deleted (DELETE /list-items/:itemId), list renamed (PATCH /:id), list deleted (DELETE /:id). For POST / (list created) — a fresh empty list is not a "change to a shared list" worth pinging; do NOT notify on list create (matches D-01 spirit; the create already auto-shares silently). CRITICAL (D-01): in PATCH /list-items/:itemId, when patch.position !== undefined (reorder), do NOT call notifyListChange — only checked/text changes notify. notifyListChange is fire-and-forget (do not await in a way that blocks the response; call it and catch).
    Extend apps/api/tests/routes/lists.test.ts: assert that a position-only PATCH does NOT enqueue a list-change push (spy notifyListChange or the coalescer), and that a checked PATCH does.
  </action>
  <verify>
    <automated>cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts</automated>
  </verify>
  <acceptance_criteria>
    notifyListChange called on add/check/text-edit/delete/rename/list-delete; NOT called on reorder (position) or list-create; lists.test.ts proves reorder-silent vs check-notifies; existing list tests still green.
  </acceptance_criteria>
  <done>List mutations push (coalesced) for the other member; reorder stays silent.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                      | Description                                                    |
| ----------------------------- | -------------------------------------------------------------- |
| list mutation → push audience | the audience must be derived from list access, not the request |

## STRIDE Threat Register

| Threat ID | Category               | Component                        | Disposition | Mitigation Plan                                                                            |
| --------- | ---------------------- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------ |
| T-05-14   | Information Disclosure | list-change push to a non-member | mitigate    | audience = owner ∪ list_shares only (same scope as SSE / GET /api/lists); never all users  |
| T-05-15   | Information Disclosure | item text in payload             | mitigate    | D-02 generic copy — coalescer payload carries no item text, only actor + list name + count |
| T-05-16   | Spoofing               | actor notified of own change     | mitigate    | excludeUserId = actorId; fan-out filters userId != actorId (D-03)                          |

</threat_model>

<verification>
- listChangeDispatcher.test.ts + lists.test.ts green.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>

<success_criteria>

- Meaningful list/item mutations push a coalesced, generic, actor-attributed notification to accessible non-actor members.
- Reorder and list-create push nothing.
- Audience strictly scoped to list access; actor suppressed.
  </success_criteria>

<output>
Create `.planning/phases/05-web-push-notifications/05-05-SUMMARY.md` when done.
</output>
