TASK_OPTIMISTIC_RENDERING_DESIGN.md42.2 KBView on GitHub
# Task optimistic rendering — server-authoritative writes

## 1) Introduction — goal, present state, future state

We want a task card that disappears when you delete, complete, or snooze it to *stay* disappeared, without the client having to run a consistency protocol to make that true. Today the three task surfaces render solely from the `userTasksSlice` Zustand mirror, which `hydrateTodoTasks` reconciles unconditionally against every `listUserTasks` response — while `optimisticDeleteTask` deliberately withholds the server write for five seconds, so any refetch that lands in that window legitimately re-inserts the card, and a reload in that window loses the delete outright. The change is to stop lying to the server: dispatch every mutation on click, make Undo a real inverse mutation against the soft-delete that already exists server-side, move the one slow destructive side effect (the Gmail draft delete) off the request path behind a 30-second grace period that a restore cancels, and take the task feed off focus/mount refetching so nothing but an explicit post-mutation invalidation can contradict the slice.

## 2) Present state

### 2.1 Architecture diagram

```text
                         ┌──────────────────────────────────────┐
                         │  React Query cache                   │
                         │  [['userTasks','listUserTasks'],      │
                         │   {status:'todo',withConversation,    │
                         │    limit:500,sortDueDate:'asc'}]      │
                         │  ONE entry, 2-3 observers            │
                         └───────────────┬──────────────────────┘
      refetch triggers                   │
      ─────────────────                  │ data.tasks
      refetchOnWindowFocus:true          ▼
      refetchOnMount:true       ┌──────────────────────┐
      staleTime:60s             │ useHydrateTasksSlice │
      9 stray invalidations     └──────────┬───────────┘
                                           │ hydrateTodoTasks(incoming)
                                           │  · upsert every incoming
                                           │  · DELETE every slice task
                                           │    with status==='todo' not
                                           │    in incoming   ◄── unconditional
                                           ▼
   ┌────────────────┐            ┌────────────────────────┐
   │ AgendaDocument │──setTasks─►│  userTasksSlice.tasks  │◄──setTasks── Cedar-OS SSE
   │ (date-ranged   │  (upsert,  │  Record<id, Task>      │             taskCreated /
   │  listUserTasks)│   no       └───────────┬────────────┘             taskUpdated
   └────────────────┘   reconcile)           │ useTodoTasks()
                                             ▼
                        ┌────────────────────────────────────────┐
                        │ TaskKanbanBoard · TaskListView ·       │
                        │ TaskGroupsAccordion                    │
                        └────────────────────────────────────────┘
                                             ▲
                                             │ removeTask / setTasks / restoreTask
                        ┌────────────────────┴───────────────────┐
                        │ useOptimisticTaskActions (1315 LOC)    │
                        │  delete: remove now, POST in 5000ms    │
                        │  complete: remove now, POST now        │
                        │  snooze: patch now, POST now           │
                        └────────────────────────────────────────┘
```

### 2.2 Step-by-step walkthrough

The delete path, which is where the defect is sharpest.

1. **User clicks delete on a card** — `onDelete` at [TaskKanbanBoard.tsx:293](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), [TaskListView.tsx:355](apps/mail/modules/userTasks/components/TaskListView.tsx) or [TaskGroupsAccordion.tsx:254](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx). All three call `optimisticDeleteTask(id)` with `showUndo` defaulted to `true`.

2. **`optimisticDeleteTask` resolves the task** — [use-optimistic-task-actions.ts:93](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Reads `getTask(taskId)` from the slice, falling back to `getConversation(conversationId).data.userTasks` when the task is not in the slice (the conversation-timeline callers).
   - Snapshot state captured for the undo path:
     ```ts
     const savedDateBuckets = { ...useCedarStore.getState().taskIdsByDateKey };
     const savedConversationData = getConversation(taskConversationId)?.data;
     ```

3. **Marks the conversation as updating** — `setUpdatingTasks(taskConversationId, true)` at [use-optimistic-task-actions.ts:138](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Cleared only on the undo path ([:198](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)), the error path ([:273](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)), and asynchronously by the agent-execution completion event at [clientExecutionResponseProcessors.ts:510](apps/mail/modules/cedar-os/src/store/agentConnection/responseProcessors/clientExecutionResponseProcessors.ts) — so on the success path the flag is only cleared if that execution actually completes.

4. **Optimistic removal from the slice** — `removeTask(taskId)` at [userTasksSlice.ts:523](apps/mail/modules/userTasks/slice/userTasksSlice.ts) deletes the key and bumps `lastTasksFetchedAt`. The card vanishes. This is the only step that makes the UI change.

5. **`cancelQueries` band-aid** — [use-optimistic-task-actions.ts:146](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Cancels `listUserTasks` fetches *in flight at this instant*. A fetch started 50ms later is unaffected — this is the core inadequacy.

6. **Date-bucket bookkeeping** — [use-optimistic-task-actions.ts:149-155](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) filters the id out of every `taskIdsByDateKey` bucket. Nothing renders from these buckets (see Phase 1).

7. **Conversation fan-out** — [use-optimistic-task-actions.ts:158-176](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) writes the same intent into `conversationsSlice` via `setConversations` *and* into the `crm.getConversation` React Query cache via `setQueryData`. Third write of one intent.

8. **Undo toast** — [use-optimistic-task-actions.ts:189-225](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Its `onClick` flips a closure variable `undoClicked`, restores the three snapshots, and clears the pending timeout from `pendingDeletionsRef`.

9. **Deferred server write** — `setTimeout(..., showUndo ? 5000 : 0)` at [use-optimistic-task-actions.ts:228](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). **For 5000ms the server has not been told anything.**
   - State of the world during the window:
     ```json
     { "client": { "sliceHasTask": false, "cardVisible": false },
       "server": { "status": "todo" },
       "rqCache":  { "containsTask": true } }
     ```

10. **Any refetch lands** — triggered by `refetchOnWindowFocus` / `refetchOnMount` ([query-provider.tsx:50-56](apps/mail/providers/query-provider.tsx), never overridden by a task surface) or one of the nine stray `listUserTasks` invalidations. The response correctly still contains the task.

11. **`useHydrateTasksSlice` fires** — [use-hydrate-tasks-slice.ts:25](apps/mail/modules/userTasks/hooks/use-hydrate-tasks-slice.ts). Its `useEffect` depends on the `tasks` array identity. Because `structuralSharing: true` ([query-provider.tsx:56](apps/mail/providers/query-provider.tsx)) preserves the array reference when the payload is byte-identical, the effect only re-runs when *some* field actually changed — a `withConversation` join field, an agent write, another task arriving. **This is precisely why the bug is intermittent, and why it will not reproduce on demand.**

12. **`hydrateTodoTasks` re-inserts the task** — [userTasksSlice.ts:486-518](apps/mail/modules/userTasks/slice/userTasksSlice.ts). It upserts every incoming task and deletes only those absent from the response. The deleted task is present, so it comes back.
    ```json
    { "sliceHasTask": true, "cardVisible": true, "elapsedSinceDelete": "1400ms" }
    ```

13. **The timeout finally fires** — `deleteTaskServer({ taskId })` at [use-optimistic-task-actions.ts:234](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts), followed by an `invalidateQueries` that eventually removes it again. Net user experience: the card disappears, returns, and disappears a second time.

14. **Server-side `deleteTask`** — [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts). Order of operations:
    - Loads the task ([:1564](apps/server/src/trpc/routes/user-tasks.ts)).
    - Extracts `draftId` / `threadId` / `emailHeaderMessageId` from `taskActionData` ([:1583](apps/server/src/trpc/routes/user-tasks.ts)).
    - **Awaits `getActiveConnectionForUserId` then `deleteDraft`** ([:1598-1636](apps/server/src/trpc/routes/user-tasks.ts)) — an irreversible Gmail call, on the request path, before anything is written.
    - Only then writes `status: 'deleted'` ([:1638-1651](apps/server/src/trpc/routes/user-tasks.ts)) and `safeReindexConversation`.
    - Does **not** call `cancelTaskScheduling`, unlike `updateTask` ([:696](apps/server/src/trpc/routes/user-tasks.ts)) and the due-date path ([:848](apps/server/src/trpc/routes/user-tasks.ts)).

**Reload during the window** is the worst case: the timer dies with the page, so the server is never told; and `listUserTasks` is persisted to IndexedDB — the blacklist at [query-persistence/index.ts:61-73](apps/mail/lib/query-persistence/index.ts) contains only `documents.getDoc` and `aop.getCompositePlaybook` — so the deleted card is restored from disk with its original `dataUpdatedAt`. The delete is lost in both directions at once.

**Corrections to earlier assumptions, recorded so they are not re-litigated:**

- The three surfaces do **not** each mount their own query. [TaskKanbanBoard.tsx:158-164](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), [TaskListView.tsx:159-165](apps/mail/modules/userTasks/components/TaskListView.tsx) and [TaskGroupsAccordion.tsx:61-67](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx) pass byte-identical input, so React Query keeps one cache entry with 2-3 observers and dedupes the network. Consolidation buys fewer `refetchOnMount` triggers, not less traffic — it is not a correctness fix on its own.
- Complete does **not** defer on any task surface. All three call the immediate `optimisticCompleteTask` ([:935](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)). Only [FilterSortConfigurationRow.tsx:1589](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx), [ConversationTaskRow.tsx:149](apps/mail/modules/conversations/components/strategicOverview/ConversationTaskRow.tsx) and [TimelineTaskItem.tsx:174](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) use `optimisticCompleteTaskDelayed`. **Complete still flakes**, which is the evidence that the 5s window is a contributing cause and not the whole story.
- Soft delete already exists server-side ([user-tasks.ts:1638](apps/server/src/trpc/routes/user-tasks.ts) sets `status:'deleted'`; [listUserTasks:91-97](apps/server/src/trpc/routes/user-tasks.ts) filters it out). Restore already works: `updateTaskStatus` ([:1206-1250](apps/server/src/trpc/routes/user-tasks.ts)) constrains the *target* status to `todo|done` but its `WHERE` is only `id + userId` — there is no gate on the current status, so `updateTaskStatus({ status: 'todo' })` un-deletes a `deleted` row today.
- Tasks are the outlier in this repo. Threads already ship immediate-write plus inverse-undo: `optimisticDeleteThreads` at [use-optimistic-actions.ts:428-476](apps/mail/modules/threads/rendering/use-optimistic-actions.ts) calls the server without a timer and pushes an undo entry; `executeUndo` at [:806-885](apps/mail/modules/threads/rendering/use-optimistic-actions.ts) dispatches real inverse mutations; `UNDO_TTL_MS = 30_000` at [threadSlice.ts:217](apps/mail/modules/threads/threadList/store/threadSlice.ts).

**Dead and near-dead code on this path:**

- `executionIdsByDateKey`, `tasksViewSelectedDate`, `navigateTasksViewDateLeft/Right`, `getTasksForDateKey`, `getExecutionsForDateKey`, `getTaskIdsForDateKey`, `getTasksForDate`, `getTasksBeforeDate`, `getTasksAfterDate` ([userTasksSlice.ts:331-345, 757-863, 901-993](apps/mail/modules/userTasks/slice/userTasksSlice.ts)) have **no consumers** outside the slice and [UserTasksDebuggerTab.tsx](apps/mail/modules/debugger/components/UserTasksDebuggerTab.tsx). `setExecutionIdsByDateKey` is never called at all.
- `taskIdsByDateKey` is written by [TaskCommandBar.tsx:185-208](apps/mail/modules/userTasks/components/TaskCommandBar.tsx) and by nine sites in the optimistic hook, and read only by the debugger tab. No rendering surface consumes it.
- [use-organized-tasks.ts](apps/mail/modules/userTasks/hooks/use-organized-tasks.ts) (282 LOC) has zero consumers.
- [messageRenderers.tsx:1102](apps/mail/modules/cedar-os/src/components/renderers/messageRenderers.tsx) invalidates `queryKey: ['userTasks']`, which cannot partial-match the tRPC key shape `[['userTasks','listUserTasks'], input]` — a silent no-op.

**Out of scope, documented because it will otherwise be mistaken for this bug:** every task mutation calls `triggerExecuteFromClientSend` ([use-optimistic-task-actions.ts:250](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and five other sites) with an event summary like `User DELETED task: "..."`. That execution's sub-agent has a `create-task` tool. A near-duplicate task appearing seconds later is indistinguishable to the user from "it popped back in", and **no client-side rendering change fixes it**. Phase 6 measures it.

## 3) Designed state

### 3.1 Architecture diagram

```text
                         ┌──────────────────────────────────────┐
                         │  React Query cache                   │
                         │  listUserTasks({status:'todo',...})   │
                         │  refetchOnWindowFocus: FALSE          │
                         │  refetchOnMount:       FALSE          │
                         │  staleTime:            5 min          │
                         └───────────────┬──────────────────────┘
   refetch triggers now ONLY:            │
   · explicit refetch on /tasks entry    │ data.tasks
   · post-mutation invalidation          ▼
                              ┌──────────────────────┐
                              │ useTodoTasksQuery()  │  ← single owner
                              │  query + hydration   │
                              └──────────┬───────────┘
                                         │ hydrateTodoTasks
                                         ▼
                            ┌────────────────────────┐
                            │  userTasksSlice.tasks  │
                            │  + taskUndoStack       │
                            └───────────┬────────────┘
                                        │ useTodoTasks()
                                        ▼
                     ┌──────────────────────────────────────┐
                     │ TaskKanbanBoard · TaskListView ·     │
                     │ TaskGroupsAccordion                  │
                     └──────────────────────────────────────┘
                                        ▲
                                        │ removeTask (instant)
                     ┌──────────────────┴───────────────────┐
                     │ useOptimisticTaskActions             │
                     │  delete: remove + POST *now*          │
                     │  undo:   POST updateTaskStatus todo   │
                     └──────────────────┬───────────────────┘
                                        │
   ═════════════════════════════════════▼══════════════════════════
                                    SERVER
   deleteTask:  cancelTaskScheduling → UPDATE status='deleted'
                → reindex → RESPOND  ◄── ~150-350ms, no Gmail call
                     └─► void IIFE: wait 30s
                                    → re-read row
                                    → status still 'deleted'? deleteDraft
                                    → status back to 'todo'?  skip
```

### 3.2 Step-by-step walkthrough

1. **User clicks delete** — unchanged call sites; `optimisticDeleteTask(id)` in [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).

2. **Resolve the task and push an undo entry** — the task object is pushed onto a new `taskUndoStack` in the slice, mirroring `pushUndo` at [use-optimistic-actions.ts:449](apps/mail/modules/threads/rendering/use-optimistic-actions.ts).
   ```ts
   pushTaskUndo({ type: 'delete', taskId, task, timestamp: Date.now() })
   ```

3. **Optimistic removal** — `removeTask(taskId)`. Unchanged; the card vanishes on the same frame.

4. **Server write dispatched immediately** — `await deleteTaskServer({ taskId })`. No `setTimeout`, no `pendingDeletionsRef`, no `undoClicked` closure, no `cancelQueries`.
   - World state ~250ms later:
     ```json
     { "client": { "cardVisible": false },
       "server": { "status": "deleted", "draftDeletePending": "t+30s" } }
     ```

5. **Server `deleteTask` reordered** — [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts):
   1. Load the task; capture `draftId` / `threadId` / `emailHeaderMessageId`.
   2. `await cancelTaskScheduling(ctx.c.env, task.id)` — **new**, closing the gap versus [:696](apps/server/src/trpc/routes/user-tasks.ts).
   3. `UPDATE user_tasks SET status='deleted'`.
   4. `safeReindexConversation`.
   5. **Respond.**
   6. Post-response `void` IIFE schedules the draft cleanup:
      ```ts
      void (async () => {
        await new Promise((r) => setTimeout(r, DRAFT_DELETE_GRACE_MS)); // 30_000
        const fresh = await db.query.userTasks.findFirst({ where: eq(userTasks.id, taskId) });
        if (fresh?.status !== 'deleted') return;  // restored — leave the draft alone
        await deleteDraft(connectionId, { draftId, emailHeaderMessageId, threadId }, traceCtx);
      })();
      ```
   - **Accepted failure mode:** an ECS task restart inside the 30s window orphans one Gmail draft. That is benign, and strictly better than today's failure mode, which is a *lost delete*. It is also more durable than today's client timer, which dies on navigation or reload. This grace period was chosen explicitly over never deleting the draft, deleting it immediately, and a durable SQS delayed message.

6. **Undo toast** — the toast's `onClick` calls a real mutation, not a `clearTimeout`:
   ```ts
   await updateTaskStatusServer({ taskId, status: 'todo' })
   ```
   `updateTaskStatus` ([:1206](apps/server/src/trpc/routes/user-tasks.ts)) has no current-status gate, so this un-deletes. It re-schedules the task when `agentExecutionEnabled` is set, undoing step 5.2. Because the row flips back inside the grace window, the IIFE's re-read at step 5.6 sees `'todo'` and skips the Gmail delete — the draft survives.

7. **Undo is durable.** It survives a reload and works from another tab, because the state lives in Postgres rather than in a closure. The undo *entry* expires from the client stack after `UNDO_TTL_MS`.

8. **The feed never contradicts the slice** — `useTodoTasksQuery()` in the new [use-todo-tasks-query.ts](apps/mail/modules/userTasks/hooks/use-todo-tasks-query.ts) owns the one `queryOptions` with `refetchOnWindowFocus: false`, `refetchOnMount: false`, `staleTime: 5 * 60_000`, and calls `useHydrateTasksSlice` itself. The three surfaces each collapse to a single call.

9. **Freshness is restored explicitly** — [tasks/layout.tsx](apps/mail/app/\(routes\)/tasks/layout.tsx) issues one `refetchQueries` on route entry. Without this, the IndexedDB-persisted cache would render a consistently stale board with no network call, trading an intermittent bug for a constant one.

10. **`hydrateTodoTasks` is unchanged** and stays unconditional. It is now *correct* to be unconditional, because by the time any response can arrive the server already knows about the mutation.

### 3.3 Schema

No database migration. `user_tasks.status` already carries the soft-delete state this design depends on; it is reproduced below because the restore path is defined in terms of it. The changes are entirely in the frontend slice types.

Full schema:

```ts
// ── apps/server/src/db/aop-schema.ts — EXISTING, unchanged, shown for reference ──
// The soft-delete states this design relies on already exist and are already
// filtered out of listUserTasks (user-tasks.ts:91-97).
type UserTaskStatus = 'todo' | 'done' | 'deleted' | 'agent_deleted' | 'recommended';

// ── apps/mail/modules/userTasks/slice/userTasksSlice.ts ──

/** NEW — one entry per undoable task mutation. Mirrors threadSlice's undo entry shape. */
export interface TaskUndoEntry {
  type: 'delete' | 'complete';        // NEW — which inverse mutation to dispatch
  taskId: string;                     // NEW
  task: HydratedUserTask;             // NEW — full row, for instant local restore
  threadId?: string;                  // NEW — set by optimisticDeleteTaskAndThread (compound undo)
  timestamp: number;                  // NEW — TTL basis, UNDO_TTL_MS = 30_000
}

export interface UserTasksState {
  tasks: Record<string, HydratedUserTask>;
  scheduledExecutions: Record<string, ScheduledExecution>;
  taskUndoStack: TaskUndoEntry[];     // NEW
  taskLabels: TaskLabel[];
  snoozeDialogOpen: boolean;
  snoozeDialogTaskId: string | null;
  taskSelection: string[];
  taskSelectionAnchorId: string | null;
  executingTaskIds: string[];
  sortingConfiguration: SortingConfiguration;
  lastTasksFetchedAt: number;
  lastExecutionsFetchedAt: number;
  // REMOVED: taskIdsByDateKey       — written by 10 sites, rendered by none
  // REMOVED: executionIdsByDateKey  — no writer at all
  // REMOVED: tasksViewSelectedDate  — read only by the debugger tab
}

export interface UserTasksSlice extends UserTasksState {
  // Task management — unchanged
  setTasks: (tasks: Record<string, HydratedUserTask>) => void;
  hydrateTodoTasks: (incoming: HydratedUserTask[]) => void;
  getTask: (taskId: string) => HydratedUserTask | undefined;
  getTasks: () => HydratedUserTask[];
  getTasksByStatus: (status: 'todo' | 'done') => HydratedUserTask[];
  removeTask: (taskId: string) => void;
  removeEmailTasksForConversation: (conversationId: string) => void;
  restoreTask: (taskId: string, task: HydratedUserTask) => void;
  updateTask: (taskId: string, updates: Partial<HydratedUserTask>) => void;
  markTaskAsRead: (taskId: string) => void;
  clearTasks: () => void;

  pushTaskUndo: (entry: TaskUndoEntry) => void;              // NEW
  popTaskUndo: () => TaskUndoEntry | undefined;              // NEW — drops TTL-expired entries
  clearTaskUndoStack: () => void;                            // NEW

  // Snooze dialog, bulk selection, executing state, scheduled executions,
  // task labels, sorting configuration — all unchanged.

  // REMOVED: setTaskIdsByDateKey, setTaskIdsForDateKey, setExecutionIdsByDateKey,
  //          getTaskIdsForDateKey, getExecutionIdsForDateKey, getTasksForDateKey,
  //          getExecutionsForDateKey, getTasksForDate, getTasksBeforeDate,
  //          getTasksAfterDate, getScheduledExecutionsAfterDate,
  //          setTasksViewSelectedDate, navigateTasksViewDateLeft,
  //          navigateTasksViewDateRight
}
```

```ts
// ── apps/server/src/trpc/routes/user-tasks.ts ──
/** NEW — grace period between the delete response and the irreversible Gmail draft delete. */
const DRAFT_DELETE_GRACE_MS = 30_000;
```

Relationship diagram:

```text
┌──────────────────────────────┐
│ user_tasks            (pg)   │
│──────────────────────────────│
│ id            uuid  PK       │
│ userId        text           │──FK──► user.id
│ conversationId uuid          │──FK──► conversations.id
│ taskGroupId   uuid  NULL     │──FK──► task_groups.id
│ status        enum           │  'todo'|'done'|'deleted'|
│                              │  'agent_deleted'|'recommended'
│ dueDate       timestamptz    │
│ agentExecutionEnabled bool   │
│ taskActionData jsonb NULL    │
│   ▼ contains (channel='email')
│   ┌────────────────────────┐ │
│   │ threadId       string  │ │──ref──► Gmail thread
│   │ draftId        string? │ │──ref──► Gmail draft ◄─ deleted after
│   │ emailHeaderMessageId?  │ │                        DRAFT_DELETE_GRACE_MS,
│   └────────────────────────┘ │                        skipped if status
└───────────┬──────────────────┘                        returned to 'todo'
            │
            │ 1:1  (hydration; status='deleted' rows filtered out by listUserTasks)
            ▼
┌──────────────────────────────┐        ┌────────────────────────────┐
│ userTasksSlice.tasks         │        │ userTasksSlice.            │
│ Record<taskId,               │        │   taskUndoStack            │
│        HydratedUserTask>     │◄─1:N───│  TaskUndoEntry[]           │
│                              │ taskId │────────────────────────────│
│ id · status · dueDate ·      │        │ type ('delete'|'complete') │
│ taskActionData · ...         │        │ taskId ──────────┘         │
└───────────┬──────────────────┘        │ task  (full snapshot)      │
            │ useTodoTasks()            │ threadId? ──FK──► threads  │
            │ (status === 'todo')       │ timestamp (TTL 30s)        │
            ▼                           └────────────────────────────┘
┌──────────────────────────────────────────────┐
│ TaskKanbanBoard · TaskListView ·             │
│ TaskGroupsAccordion                          │
└──────────────────────────────────────────────┘
```

## 4) Implementation phases

### Phase 1 — Delete the dead date-bucket subsystem

**Goal:** Remove ~550 lines of state that nothing renders, so later phases touch a smaller surface.

- [ ] Remove `taskIdsByDateKey`, `executionIdsByDateKey` and `tasksViewSelectedDate` from `UserTasksState` and `initialUserTasksState` in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts).
- [ ] Remove the `TaskIdsByDateKey` / `ExecutionIdsByDateKey` types and `EMPTY_DATE_KEY_MAP` from [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts), keeping the `TaskDateKey` type only if a remaining consumer needs it.
- [ ] Remove the date-key actions and getters from `UserTasksSlice` and its implementation in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts): `setTaskIdsByDateKey`, `setTaskIdsForDateKey`, `setExecutionIdsByDateKey`, `getTaskIdsForDateKey`, `getExecutionIdsForDateKey`, `getTasksForDateKey`, `getExecutionsForDateKey`, `getTasksForDate`, `getTasksBeforeDate`, `getTasksAfterDate`, `getScheduledExecutionsAfterDate`, `setTasksViewSelectedDate`, `navigateTasksViewDateLeft`, `navigateTasksViewDateRight`.
- [ ] Remove all nine `savedDateBuckets` / `setTaskIdsByDateKey` blocks from [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) (lines 129-155, 201, 259, 342, 364-377, 462, 528, 602, 1036-1037, 1073, 1102-1108, 1151) and the now-unused `getDateKeyForDate` helper at [:16](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Remove `addTaskToDateBucket` / `removeTaskFromDateBucket` and their store subscriptions from [TaskCommandBar.tsx:139-210](apps/mail/modules/userTasks/components/TaskCommandBar.tsx), plus the `optimisticDateKeyRef` they feed.
- [ ] Remove the date-bucket panels from [UserTasksDebuggerTab.tsx:49-51, 93-105, 142-144, 182, 195-196](apps/mail/modules/debugger/components/UserTasksDebuggerTab.tsx).
- [ ] Delete [use-organized-tasks.ts](apps/mail/modules/userTasks/hooks/use-organized-tasks.ts) (282 LOC, zero consumers) and any export of it from [modules/userTasks/index.ts](apps/mail/modules/userTasks/index.ts).
- [ ] Fix the no-op invalidation at [messageRenderers.tsx:1102](apps/mail/modules/cedar-os/src/components/renderers/messageRenderers.tsx) — replace `queryKey: ['userTasks']` with `trpc.userTasks.listUserTasks.queryKey()`.
- [ ] Verify no remaining references: `rg "taskIdsByDateKey|executionIdsByDateKey|tasksViewSelectedDate|useOrganizedTasks" apps/mail` returns nothing.

**Tests:**

- [ ] Update [tests/modules/userTasks/userTasksSlice.test.ts](apps/mail/tests/modules/userTasks/userTasksSlice.test.ts) to drop any assertions on the removed state, keeping `hydrateTodoTasks` coverage intact.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks/userTasksSlice.test.ts`
- [ ] `pnpm --filter @cedar/mail types`

### Phase 2 — Single task-feed hook with explicit freshness

**Goal:** Make focus and mount stop refetching the task feed, without letting the persisted cache go stale.

- [ ] Create `apps/mail/modules/userTasks/hooks/use-todo-tasks-query.ts` exporting `TODO_TASKS_INPUT` (`{ status: 'todo', withConversation: true, limit: 500, sortDueDate: 'asc' }`), `todoTasksQueryKey()`, and `useTodoTasksQuery()` which calls `useQuery` with `refetchOnWindowFocus: false`, `refetchOnMount: false`, `staleTime: 5 * 60_000` and then `useHydrateTasksSlice(data?.tasks)`.
- [ ] Replace the inline `queryOptions` + `useHydrateTasksSlice` pair in [TaskKanbanBoard.tsx:138-152](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) with `useTodoTasksQuery()`.
- [ ] Replace the same pair in [TaskListView.tsx:158-166](apps/mail/modules/userTasks/components/TaskListView.tsx) with `useTodoTasksQuery()`, leaving the separate `status: 'done'` query untouched.
- [ ] Replace the same pair in [TaskGroupsAccordion.tsx:60-67](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx) with `useTodoTasksQuery()`.
- [ ] Add a single `refetchQueries({ queryKey=[redacted] })` on mount in [tasks/layout.tsx](apps/mail/app/\(routes\)/tasks/layout.tsx), so entering `/tasks/*` always reconciles against the server despite `refetchOnMount: false`.
- [ ] Point `refetchTasks` at [TaskListView.tsx:273](apps/mail/modules/userTasks/components/TaskListView.tsx) at the shared `todoTasksQueryKey()` helper.

**Tests:**

- [ ] New `apps/mail/tests/modules/userTasks/todoTasksQuery.test.ts` asserting the hook's resolved options have `refetchOnWindowFocus === false`, `refetchOnMount === false`, `staleTime === 300_000`.
- [ ] New `apps/mail/tests/modules/userTasks/hydrationDoesNotResurrect.test.tsx`: render a surface, `removeTask(id)`, then deliver a `listUserTasks` response that still contains the task *with a changed field* (so `structuralSharing` cannot suppress the effect), and assert the card is still absent once the mutation has been dispatched. This is the primary regression lock.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`

### Phase 3 — Server: fast, restore-safe `deleteTask`

**Goal:** `deleteTask` responds in one round trip, and the irreversible Gmail delete becomes cancellable by a restore.

- [ ] Add `const DRAFT_DELETE_GRACE_MS = 30_000;` near the top of [user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts).
- [ ] In `deleteTask` [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts), move the `getActiveConnectionForUserId` + `deleteDraft` block ([:1598-1636](apps/server/src/trpc/routes/user-tasks.ts)) out of the request path.
- [ ] Add `await cancelTaskScheduling(ctx.c.env, task.id)` before the status update in `deleteTask`, matching [:696](apps/server/src/trpc/routes/user-tasks.ts).
- [ ] After the status update and `safeReindexConversation`, schedule the draft cleanup in a post-response `void` IIFE that waits `DRAFT_DELETE_GRACE_MS`, re-reads the row, and returns early unless `status === 'deleted'`.
- [ ] Keep the existing swallow-and-log behavior for `deleteDraft` failures ([:1626-1636](apps/server/src/trpc/routes/user-tasks.ts)) inside the IIFE, and emit a `createStructuredLog` diagnostic for both the skip and the delete so the grace period is observable in Axiom.
- [ ] Ensure the IIFE does not use the request-scoped `db`/`conn` handle after `conn.end()` — open its own `createDb` handle and close it in a `finally`.
- [ ] In `updateTaskStatus` [user-tasks.ts:1206](apps/server/src/trpc/routes/user-tasks.ts), re-schedule the task via `scheduleTask` when the target status is `'todo'`, the previous status was `'deleted'`, and `agentExecutionEnabled` is true — restoring what step 3.2 cancelled.
- [ ] `pnpm deps:check` (this touches a tRPC route that imports task-scheduling services).

**Tests:**

- [ ] New server test: mock `deleteDraft` to never resolve; assert `deleteTask` still resolves and the row is `status: 'deleted'`.
- [ ] New server test: call `deleteTask`, then `updateTaskStatus({ status: 'todo' })` inside the grace window with fake timers; advance past `DRAFT_DELETE_GRACE_MS` and assert `deleteDraft` was never called.
- [ ] New server test: call `deleteTask`, advance past the grace window with no restore, assert `deleteDraft` was called exactly once.
- [ ] New server test: `deleteTask` calls `cancelTaskScheduling`; `updateTaskStatus('todo')` on a previously-deleted `agentExecutionEnabled` task calls `scheduleTask`.
- [ ] Regression lock: `listUserTasks` excludes `status: 'deleted'` rows.
- [ ] `pnpm --filter @cedar/server test`

### Phase 4 — Client: immediate writes and inverse undo

**Goal:** Delete the 5-second lie and the bookkeeping that existed only to serve it.

- [ ] Add `taskUndoStack`, `pushTaskUndo`, `popTaskUndo` and `clearTaskUndoStack` to [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts), with `UNDO_TTL_MS = 30_000` and TTL filtering on pop, mirroring [threadSlice.ts:217, 2501-2561](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Rewrite `optimisticDeleteTask` [use-optimistic-task-actions.ts:93-298](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) to push an undo entry, remove from the slice, and `await deleteTaskServer` immediately — deleting `pendingDeletionsRef`, the `undoClicked` closure, the `setTimeout`, the `cancelQueries` call at [:146](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and the `savedConversationData` snapshot.
- [ ] Change the undo toast action to dispatch `updateTaskStatus({ taskId, status: 'todo' })` and restore the slice from the undo entry, replacing the snapshot-restore block at [:196-224](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Clear `setUpdatingTasks(conversationId, false)` on the success path of `optimisticDeleteTask`, closing the gap left by [:138](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Delete `optimisticCompleteTaskDelayed` [use-optimistic-task-actions.ts:1001-1179](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and `pendingCompletionsRef`.
- [ ] Repoint [FilterSortConfigurationRow.tsx:1589](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx), [ConversationTaskRow.tsx:149](apps/mail/modules/conversations/components/strategicOverview/ConversationTaskRow.tsx) and [TimelineTaskItem.tsx:174](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) at `optimisticCompleteTask`, and remove `optimisticCompleteTaskDelayed` from the hook's return object at [:1303](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Give `optimisticCompleteTask` an undo entry of `type: 'complete'` whose inverse is `updateTaskStatus({ status: 'todo' })`, so completion gains the undo that the delayed variant used to provide.
- [ ] Make `optimisticDeleteTaskAndThread` [use-optimistic-task-actions.ts:300-313](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) push a single compound undo entry carrying `threadId`, so undo cannot restore the task while leaving its thread in the bin.
- [ ] Remove the now-redundant `cancelQueries` calls at [:359](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and [:951](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).

**Tests:**

- [ ] New `apps/mail/tests/modules/userTasks/optimisticDelete.test.tsx`: assert `deleteTask` is called **without** advancing fake timers. This fails against the current code and is the regression lock for the whole design.
- [ ] Undo path: after delete, invoking the toast action calls `updateTaskStatus({ status: 'todo' })` and the card returns.
- [ ] Failure path: a rejected `deleteTask` restores the card and surfaces an error toast.
- [ ] Compound undo: `optimisticDeleteTaskAndThread` then undo restores both the task and the thread.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`
- [ ] `pnpm --filter @cedar/mail types && pnpm --filter @cedar/mail lint`

### Phase 5 — Conditional: task tombstones for the non-React-Query writers

**Goal:** Close the remaining resurrection paths, only if flake survives Phases 1-4.

Do not start this phase without evidence from the Phase 4 instrumentation that a task is still being resurrected. It exists because two writers reach `state.tasks` without going through `listUserTasks`: [AgendaDocument.tsx:686-690](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx) mirrors a date-ranged query in via `setTasks` (a pure upsert with no reconciliation, [userTasksSlice.ts:466-484](apps/mail/modules/userTasks/slice/userTasksSlice.ts)), and the Cedar-OS SSE processors at [clientExecutionResponseProcessors.ts:181, 245](apps/mail/modules/cedar-os/src/store/agentConnection/responseProcessors/clientExecutionResponseProcessors.ts) write directly on `taskCreated` / `taskUpdated`.

- [ ] Add `pendingTaskDeletions: Record<string, number>` to [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts) with `addTaskTombstones` / `clearTaskTombstones`, copying the shape of [threadSlice.ts:229-232](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Add an `applyTaskTombstones` helper mirroring [threadSlice.ts:770-805](apps/mail/modules/threads/threadList/store/threadSlice.ts), with the same TTL constant as [threadSlice.ts:653](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Apply it inside **both** write doors — `hydrateTodoTasks` and `setTasks` in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts). Guarding the writes rather than one read is what makes this cover the agenda and SSE paths.
- [ ] Add the tombstone on delete and clear it on undo in [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).

**Tests:**

- [ ] New `apps/mail/tests/modules/userTasks/taskTombstones.test.ts` modelled on [draftTombstones.test.ts](apps/mail/modules/threads/threadList/store/__tests__/draftTombstones.test.ts): a tombstoned id is suppressed by `hydrateTodoTasks`, suppressed by a raw `setTasks` upsert, expires after the TTL, and is cleared by undo.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`

### Phase 6 — Measure agent task re-creation

**Goal:** Determine whether the residual "it came back" reports are the agent re-creating tasks rather than a rendering fault.

- [ ] Add a `createStructuredLog` diagnostic in `createTaskTool` recording `conversationId`, `description` and `creationRunId` on every agent-created task.
- [ ] Add a matching diagnostic on the delete path recording `conversationId` and `description`.
- [ ] Query Axiom for tasks created on a conversation within 60s of a user delete on that same conversation, over a two-week window.
- [ ] Record the rate in this doc. If it is non-trivial, open a separate design doc for the server-side fix (constraining `triggerExecuteFromClientSend` on the delete path, or deduping in `createTaskTool` against recently-deleted rows) — it is out of scope here.

**Tests:**

- [ ] Manual: delete a task with an attached draft, confirm via Axiom that the delete log fires and the grace-period skip/delete log follows 30s later.