# Tasks: Detail & Real-Time Communication — Implementation Plan

**Spec:** docs/specs/2026-05-30-tasks-detail-communication.md  ·  **Slug:** tasks-detail-communication  ·  **Wave:** 5
**Depends on:** foundation-design-system, system-communications-notifications, tasks-board-engine

## Goal
Deliver the task detail experience: a `/tasks/:id` route (full page or intercepted modal), a Tiptap-based rich text description editor, a correspondence stream that interleaves user comments and system messages with attachments, file attachments stored in R2, an immutable task audit log, and real-time WebSocket fan-out of task and notification events. This is the per-task collaboration surface that sits on top of the `tasks` board engine and consumes the shared per-tenant Durable Object for live updates.

## Architecture
This plan introduces three new tables — `task_messages`, `task_message_attachments`, `task_audit_log` — all FK'ing into upstream `tasks(id)`, `tenants(id)`, and `users(id)`. It does NOT redefine `tasks` or `task_statuses` (owned by `tasks-board-engine`).

Data flow:
- Detail page reads the upstream `tasks` row (via the board engine's `GET /api/tasks/:id`-equivalent / `serializeTask`) plus this plan's `GET /api/tasks/:id/messages` and `GET /api/tasks/:id/audit`.
- Mutations to a task (status/assignee/priority/due-date/description) write a `task_audit_log` row and a `task_messages` row with `message_type = 'system'`, then trigger a real-time broadcast.
- Comments are authored in Tiptap, serialized to HTML, server-sanitized via an allowlist, stored in `task_messages.content`, and re-sanitized client-side with DOMPurify before render.
- Real-time: this plan OWNS the task event contract (`RealtimeEvent`, `InboundCommand`, `TaskMessage`) and the Worker-side broadcast triggers. It EXTENDS the existing `TenantRealtimeDO` class (locked export, addressed via the `DO_REALTIME` binding) with a `/broadcast` handler and per-tenant WebSocket session tracking. Comment/assignment notifications are emitted through the locked `createNotification` + `deliverNotification` pipeline using `NotificationType` values `'task_comment'` / `'task_assigned'`.

Boundary note (real-time-infrastructure): `real-time-infrastructure` (P036) is same-wave and is NOT a dependency of this plan. This plan defines the minimal task/notification event contract and broadcast path it needs now. The implementing agent must treat `TenantRealtimeDO` and `DO_REALTIME` as the single shared per-tenant object and only ADD the task handlers/events — never fork a second DO class. If `real-time-infrastructure` later generalizes the DO, the event union defined here is the authoritative task contract it must preserve.

Upstream tables consumed: `tasks`, `task_statuses`, `tenants`, `users`, `notifications`, `user_preferences`.
Upstream exports consumed: `TenantRealtimeDO`, `DO_REALTIME`, `STORAGE` (R2), `TaskObject`, `serializeTask`, `TaskStatus`, `createNotification`, `deliverNotification`, `NotificationAdapter`, `Attachment`, `authMiddleware`, `requirePermission`, `requireModuleEnabled`, `tenantQuery`, `buildPaginated`, `Dialog`, `Sheet`, `Button`, `Avatar`, `Badge`, `EmptyState`, `Spinner`, `Toast`, `toast`, `useDirection`, `cn`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): message/audit/attachment routes, WebSocket upgrade route, broadcast triggers; bindings `DB` (Neon via Hyperdrive), `STORAGE` (R2), `DO_REALTIME` (Durable Object namespace), `RATE_LIMITER_WEBHOOK`.
- **apps/zync-app** (Vite + React, React Router): detail route/modal, Tiptap editor, correspondence stream, attachment chips, PDF viewer, WebSocket client + Zustand/TanStack Query integration.
- **packages/db** (Drizzle): schema for the three new tables, queries.
- **packages/types**: `TaskMessage`, `RealtimeEvent`, `InboundCommand` exported wire types.
- **packages/ui**: design-system primitives (consumed, not created here).
- Libraries: Tiptap v2 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-image`, `@tiptap/extension-link`, `@tiptap/extension-mention`, `@tiptap/extension-placeholder`, `@tiptap/extension-character-count`, `@tiptap/extension-text-direction`), `dompurify`, `react-pdf`, `@dnd-kit/*` (already present from board engine), `zod`.
- Durable Object: `TenantRealtimeDO` (extended), bound as `DO_REALTIME`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 5a — schema | 1 | packages/db/src/schema/task-comms.ts, migrations | No (blocks all) |
| 5b — server core | 2, 3, 4 | packages/db/src/queries, packages/types, apps/zync-api routes | Tasks 2 & 3 parallel after 1; 4 after 2 |
| 5c — realtime | 5, 6 | apps/zync-api DO + broadcast, packages/types events | After 2,3 |
| 5d — client | 7, 8, 9, 10 | apps/zync-app detail, editor, stream, attachments | 8,9,10 parallel after 7 |
| 5e — realtime client + audit UI | 11, 12 | apps/zync-app ws client, audit interleave | After 5,7 |

## Tasks

### Task 1: Database schema — task_messages, task_message_attachments, task_audit_log
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/task-comms.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export)
- Create: `packages/db/migrations/<timestamp>_task_comms.sql`
**Steps:**
- [ ] Define the three tables in Drizzle (pgTable) matching the DDL below exactly.
- [ ] Add indexes for the stream and attachment lookups.
- [ ] Re-export the new tables from the package schema barrel so queries can import them.
- [ ] Generate the SQL migration (drizzle-kit) and verify it emits canonical Postgres (UUID PKs, TIMESTAMPTZ, BOOLEAN, JSONB).
**Schema / Interfaces:**
```sql
CREATE TABLE task_messages (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id      UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  author_id    UUID REFERENCES users(id) ON DELETE SET NULL, -- NULL for system messages
  message_type TEXT NOT NULL CHECK (message_type IN ('comment', 'system')),
  content      TEXT NOT NULL,        -- sanitized HTML for comments; plain text for system
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at   TIMESTAMPTZ           -- soft delete; author may delete own comment
);
CREATE INDEX idx_task_messages_stream ON task_messages (task_id, created_at);
CREATE INDEX idx_task_messages_tenant ON task_messages (tenant_id);

CREATE TABLE task_message_attachments (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  message_id  UUID NOT NULL REFERENCES task_messages(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  filename    TEXT NOT NULL,
  url         TEXT NOT NULL,         -- R2 signed URL (1h TTL, re-signed on read)
  r2_key      TEXT NOT NULL,         -- R2 object key for deletion
  size_bytes  INTEGER NOT NULL,
  mime_type   TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_msg_attachments_message ON task_message_attachments (message_id);

CREATE TABLE task_audit_log (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id        UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tenant_id      UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  actor_id       UUID NOT NULL REFERENCES users(id),
  action         TEXT NOT NULL,      -- open vocabulary: 'status_changed','assigned','priority_changed','due_date_changed','edited', ...
  previous_value JSONB,
  new_value      JSONB,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_audit_log_task ON task_audit_log (task_id, created_at);
```
Note: `action` is intentionally open vocabulary (spec: "status_changed, assigned, edited, etc.") — plain TEXT, no closed CHECK. `message_type` is the only closed enum.
Note (upstream conflict, do NOT resolve here): `tasks.description` is owned by `tasks-board-engine`; this spec requires it to hold Tiptap JSON as **JSONB**. The board-engine schema currently declares it `TEXT` with a contradicting "JSONB" comment. Do not emit a CREATE/ALTER on `tasks` in this plan — flag the board-engine owner to store `description` as JSONB per the canonical dialect.
**Acceptance:**
- [ ] Migration applies cleanly to a Neon branch; all three tables exist with the indexes.
- [ ] PKs default `gen_random_uuid()`; all timestamps are TIMESTAMPTZ; `message_type` rejects values outside `('comment','system')`.
- [ ] FK deletes cascade from `tasks`/`tenants`; `author_id` nullifies on user delete.

### Task 2: DB queries — messages, attachments, audit
**Blocks:** 4, 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/task-messages.ts`
- Create: `packages/db/src/queries/task-audit.ts`
- Modify: `packages/db/src/queries/index.ts` (re-export)
**Steps:**
- [ ] Implement `listTaskMessages(db, tenantId, taskId, { limit, cursor })` — returns comment + system rows for the task, ordered by `created_at` ASC, joining attachments and author display fields; soft-deleted rows returned with `content` replaced by a deleted placeholder marker and attachments omitted. Use `tenantQuery` scoping.
- [ ] Implement `createTaskMessage(db, { tenantId, taskId, authorId, messageType, content })` — inserts a row; `authorId` NULL for system.
- [ ] Implement `recordTaskSystemMessage(db, { tenantId, taskId, text })` — convenience wrapper inserting `message_type='system'`, `author_id=NULL`, plain-text `content`.
- [ ] Implement `softDeleteTaskMessage(db, { tenantId, messageId, authorId })` — sets `deleted_at = now()` only when the row's `author_id` matches and `message_type='comment'`; returns affected row or null.
- [ ] Implement `addMessageAttachment(db, { tenantId, messageId, filename, url, r2Key, sizeBytes, mimeType })` and `getMessageAttachment(db, tenantId, attachmentId)` / `deleteMessageAttachmentRow(db, tenantId, attachmentId)`.
- [ ] Implement `recordTaskAudit(db, { tenantId, taskId, actorId, action, previousValue, newValue })` and `listTaskAudit(db, tenantId, taskId, { limit, cursor })`.
**Schema / Interfaces:**
```ts
// packages/db/src/queries/task-messages.ts
export interface TaskMessageRow {
  id: string;
  taskId: string;
  authorId: string | null;
  authorName: string | null;
  authorAvatarUrl: string | null;
  messageType: 'comment' | 'system';
  content: string;          // sanitized HTML (comment) or plain text (system)
  deleted: boolean;
  attachments: TaskAttachmentRow[];
  createdAt: string;        // ISO 8601
}
export interface TaskAttachmentRow {
  id: string; filename: string; url: string; r2Key: string;
  sizeBytes: number; mimeType: string; createdAt: string;
}
export function listTaskMessages(db: Db, tenantId: string, taskId: string, opts: { limit: number; cursor?: string }): Promise<{ rows: TaskMessageRow[]; nextCursor: string | null }>;
export function createTaskMessage(db: Db, args: { tenantId: string; taskId: string; authorId: string | null; messageType: 'comment' | 'system'; content: string }): Promise<TaskMessageRow>;
export function recordTaskSystemMessage(db: Db, args: { tenantId: string; taskId: string; text: string }): Promise<TaskMessageRow>;
export function softDeleteTaskMessage(db: Db, args: { tenantId: string; messageId: string; authorId: string }): Promise<TaskMessageRow | null>;
export function addMessageAttachment(db: Db, args: { tenantId: string; messageId: string; filename: string; url: string; r2Key: string; sizeBytes: number; mimeType: string }): Promise<TaskAttachmentRow>;
export function getMessageAttachment(db: Db, tenantId: string, attachmentId: string): Promise<TaskAttachmentRow | null>;
export function deleteMessageAttachmentRow(db: Db, tenantId: string, attachmentId: string): Promise<void>;

// packages/db/src/queries/task-audit.ts
export interface TaskAuditRow {
  id: string; taskId: string; actorId: string; actorName: string | null;
  action: string; previousValue: unknown; newValue: unknown; createdAt: string;
}
export function recordTaskAudit(db: Db, args: { tenantId: string; taskId: string; actorId: string; action: string; previousValue: unknown; newValue: unknown }): Promise<void>;
export function listTaskAudit(db: Db, tenantId: string, taskId: string, opts: { limit: number; cursor?: string }): Promise<{ rows: TaskAuditRow[]; nextCursor: string | null }>;
```
**Acceptance:**
- [ ] All queries are tenant-scoped via `tenantQuery`; no cross-tenant leakage.
- [ ] `softDeleteTaskMessage` refuses system messages and non-author callers (returns null).
- [ ] `listTaskMessages` returns deleted comments as a placeholder with no attachments.

### Task 3: HTML sanitization (server) + Tiptap node-type validation + types
**Blocks:** 4, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/sanitize/comment-html.ts` (or `packages/types` utility — colocate with API usage)
- Create: `packages/types/src/task-comms.ts`
- Modify: `packages/types/src/index.ts` (re-export `TaskMessage`, `RealtimeEvent`, `InboundCommand`)
**Steps:**
- [ ] Implement `sanitizeCommentHtml(html: string): string` — allowlist-based. Allowed tags ONLY: `p`, `br`, `strong`, `em`, `u`, `s`, `h1`, `h2`, `h3`, `ul`, `ol`, `li`, `blockquote`, `code`, `pre`, `a`, `img`. Strip every other tag and ALL attributes except: on `a` keep only `href` where the scheme is `http:`, `https:`, or `mailto:`; on `img` keep only `src` where the host matches `*.r2.dev` or the configured R2 public domain. Any disallowed scheme/host → drop the attribute (and drop the element if it becomes empty/meaningless).
- [ ] Implement `validateTaskDescriptionJson(doc: unknown): boolean` — whitelist allowed Tiptap node `type` values for the description JSONB (paragraph, heading, text, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, image, hardBreak, mention, link mark, bold/italic/underline/strike marks, `dir` attr on paragraph/heading). Reject any node whose `type` is not allowed; never accept raw HTML. This is the server-side ALLOWED_NODE_TYPES gate (spec line 66/150).
- [ ] Define and export the wire/serialized types below.
**Schema / Interfaces:**
```ts
// packages/types/src/task-comms.ts
import type { TaskObject } from './task';                 // locked upstream serialized task
import type { Notification } from './notification';        // serialized notification shape (notifications table)

export interface TaskMessage {                             // NEW exported serialized type (wire shape)
  id: string;
  taskId: string;
  authorId: string | null;
  authorName: string | null;
  authorAvatarUrl: string | null;
  messageType: 'comment' | 'system';
  content: string;                                         // sanitized HTML | plain text
  deleted: boolean;
  attachments: TaskMessageAttachment[];
  createdAt: string;
}
export interface TaskMessageAttachment {
  id: string; filename: string; url: string;
  sizeBytes: number; mimeType: string; createdAt: string;  // r2_key NOT exposed on the wire
}

// Task real-time event contract — OWNED by this plan, broadcast by TenantRealtimeDO to clients
export type RealtimeEvent =
  | { type: 'task.updated'; taskId: string; changes: Partial<TaskObject> }
  | { type: 'task.message'; taskId: string; message: TaskMessage }
  | { type: 'task.status_changed'; taskId: string; from: string; to: string }
  | { type: 'notification.new'; notification: Notification };

// Worker → DO command envelope
export type InboundCommand =
  | { op: 'broadcast'; event: RealtimeEvent };
```
**Acceptance:**
- [ ] `sanitizeCommentHtml('<script>x</script><p onclick="y">hi<a href="javascript:1">z</a></p>')` yields `<p>hi z</p>` (script/handler/js-scheme stripped).
- [ ] `img` with non-R2 `src` has its `src` dropped; `img` with `*.r2.dev` src survives.
- [ ] `validateTaskDescriptionJson` rejects a doc containing an unlisted node type.
- [ ] `TaskMessage`, `RealtimeEvent`, `InboundCommand` are exported from `@zync/types`.

### Task 4: API routes — messages, audit, soft-delete
**Blocks:** 5  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/task-messages.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/tasks/:id/messages` — `authMiddleware`, `requireModuleEnabled('tasks')`, `requirePermission('tasks:read')`. Returns the correspondence stream via `listTaskMessages`, paginated with `buildPaginated`. Serialize rows to `TaskMessage` (omit `r2_key`).
- [ ] `POST /api/tasks/:id/messages` — `requirePermission('tasks:write')`. Zod-validate body `{ contentHtml: string, attachmentIds?: string[] }`; run `sanitizeCommentHtml` on `contentHtml`; create `message_type='comment'`; link any pre-uploaded attachments (Task 8) by re-parenting their rows to the new message; return the serialized `TaskMessage`. Then broadcast `task.message` (Task 5) and emit a `task_comment` notification to other task participants via `createNotification` + `deliverNotification`.
- [ ] `DELETE /api/tasks/:id/messages/:mid` — `requirePermission('tasks:write')`. Call `softDeleteTaskMessage` (author-only, comments-only). Broadcast a `task.message` update reflecting the deleted placeholder. 403 if not author; 404 if system message.
- [ ] `GET /api/tasks/:id/audit` — `requirePermission('tasks:read')`. Returns `listTaskAudit` paginated.
- [ ] In the upstream task-mutation handler (`PATCH /api/tasks/:id`, owned by board engine — extend, do not duplicate): on status/assignee/priority/due-date change, write `recordTaskAudit`, write a `recordTaskSystemMessage` with the spec's phrasing ("Status changed from {from} to {to} by {actor}", "Assigned to {user} by {actor}", priority/due-date equivalents), and trigger broadcasts (`task.status_changed` and/or `task.updated`). Emit `task_assigned` notification on assignment change via `createNotification` + `deliverNotification`.
**Schema / Interfaces:**
```ts
const createMessageSchema = z.object({
  contentHtml: z.string().min(1).max(100_000),
  attachmentIds: z.array(z.string().uuid()).max(10).optional(),
});
// GET    /api/tasks/:id/messages   → PaginatedResponse<TaskMessage>
// POST   /api/tasks/:id/messages   → TaskMessage
// DELETE /api/tasks/:id/messages/:mid → 204
// GET    /api/tasks/:id/audit      → PaginatedResponse<TaskAuditRow>
```
**Acceptance:**
- [ ] Every route validates input with zod and enforces the permission from the spec's permission table.
- [ ] Comment content is server-sanitized before storage (asserted by a test posting `<script>` and reading back clean HTML).
- [ ] A status change produces exactly one audit row + one system message + one broadcast.
- [ ] System messages cannot be deleted (DELETE returns 404/403).

### Task 5: TenantRealtimeDO broadcast handler + Worker broadcast trigger
**Blocks:** 11  ·  **Blocked by:** 2, 3, 4
**Files:**
- Modify: `apps/zync-api/src/realtime/tenant-realtime-do.ts` (extend existing `TenantRealtimeDO`)
- Create: `apps/zync-api/src/realtime/broadcast.ts` (Worker-side helper)
**Steps:**
- [ ] Extend `TenantRealtimeDO` (locked export, bound as `DO_REALTIME`): keep an in-memory `Set<WebSocket>` of open sessions; on `fetch()` with an `Upgrade: websocket` header, accept the pair, `addEventListener('close'/'error')` to evict, and register the socket.
- [ ] Add a `POST /broadcast` branch to the DO `fetch()` handler: parse an `InboundCommand` (`{ op: 'broadcast', event }`), JSON-stringify the `RealtimeEvent`, and send to every open session; skip/evict closed sockets. The DO never queries Neon — data arrives fully formed.
- [ ] Implement Worker helper `broadcastToTenant(env, tenantId, event: RealtimeEvent)`: get the DO stub via `env.DO_REALTIME.idFromName('tenant:' + tenantId)` → `get()` → `stub.fetch('https://do/broadcast', { method: 'POST', body: JSON.stringify({ op: 'broadcast', event }) })`.
- [ ] Wire `broadcastToTenant` into Task 4 message/mutation handlers and into the notifications creation path so `notification.new` events fan out (piggybacking on the same DO per the comms spec).
**Schema / Interfaces:**
```ts
// apps/zync-api/src/realtime/broadcast.ts
import type { RealtimeEvent } from '@zync/types';
export async function broadcastToTenant(env: Env, tenantId: string, event: RealtimeEvent): Promise<void>;
// DO addressed by name: `tenant:${tenantId}` via env.DO_REALTIME
```
Boundary: this task ADDS task/notification handling to the shared `TenantRealtimeDO`; it must not create a second DO class. The event union is `RealtimeEvent` from Task 3.
**Acceptance:**
- [ ] Two simulated WS clients on the same tenant both receive a `task.message` event when one posts a comment.
- [ ] A client on a different tenant receives nothing (isolation by DO name).
- [ ] Closed sockets are evicted and do not throw on the next broadcast.

### Task 6: WebSocket upgrade route
**Blocks:** 11  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/ws.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/ws/:tenantId` — verify session (`authMiddleware`) and that the session's tenant matches `:tenantId` (reject cross-tenant upgrade with 403). Require `Upgrade: websocket`.
- [ ] Forward the upgrade to the tenant DO: `env.DO_REALTIME.idFromName('tenant:' + tenantId)` → `stub.fetch(request)` so the DO completes the WebSocket handshake (Task 5).
- [ ] Return the DO's `Response` with `webSocket` (status 101) to the client.
**Schema / Interfaces:**
```
GET /api/ws/:tenantId  → 101 Switching Protocols (wss://zync.is/api/ws/{tenantId})
```
**Acceptance:**
- [ ] A valid session for tenant X can open `wss://.../api/ws/{X}`; mismatched tenant gets 403.
- [ ] Non-websocket requests to the route get 426/400.

### Task 7: Task detail route + modal (full-page / intercepted)
**Blocks:** 8, 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/pages/tasks/TaskDetailPage.tsx`
- Create: `apps/zync-app/src/pages/tasks/TaskDetailModal.tsx`
- Create: `apps/zync-app/src/pages/tasks/useTaskDetail.ts`
- Modify: `apps/zync-app/src/router.tsx` (route + modal interception)
**Steps:**
- [ ] Register route `/tasks/:id`. Use the React Router modal pattern: when navigated from the board (location state carries `background`), render `TaskDetailModal` over the board (`Dialog`/`Sheet` overlay with the board visible behind); on direct URL load, render `TaskDetailPage` full-page.
- [ ] `useTaskDetail(taskId)` — TanStack Query hooks for the task (`serializeTask` shape `TaskObject`), `useTaskMessages`, and `useTaskAudit`. Guard with `tasks:read`.
- [ ] Lay out per spec: back-to-board, assignee + status header controls, inline-editable title, two-column Description (editor) / Metadata (project, priority, due date, reporter, labels, created), then the Activity/Correspondence stream + reply box.
- [ ] Apply `useDirection()` so the whole layout flips for `he-IL` (RTL); honor `prefers-reduced-motion` on the modal open/close transition.
**Schema / Interfaces:**
```ts
export function useTaskDetail(taskId: string): {
  task: TaskObject | undefined;
  isLoading: boolean;
  updateTask: (changes: Partial<TaskObject>) => void; // optimistic PATCH /api/tasks/:id
};
```
**Acceptance:**
- [ ] Direct navigation to `/tasks/:id` renders the full page; clicking a card on the board opens the modal with the board still visible behind.
- [ ] Back button / Escape closes the modal back to the board without losing board state.
- [ ] Layout mirrors correctly under Hebrew/RTL.

### Task 8: Tiptap rich-text editor (description) + paste-to-upload
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/components/editor/RichTextEditor.tsx`
- Create: `apps/zync-app/src/components/editor/EditorToolbar.tsx`
- Create: `apps/zync-app/src/components/editor/usePasteUpload.ts`
**Steps:**
- [ ] Configure Tiptap v2 with StarterKit + `extension-image`, `extension-link`, `extension-mention` (@user mentions, fed from tenant members), `extension-placeholder`, `extension-character-count` (limit 50,000 on task description), and `extension-text-direction` `Direction.configure({ defaultDirection: locale === 'he-IL' ? 'rtl' : 'ltr' })` with a per-paragraph direction toggle; `dir` persists on paragraph/heading nodes in the stored JSONB.
- [ ] Store description as Tiptap JSON in `tasks.description` (JSONB). Render read-only via Tiptap's React read-only component — never `dangerouslySetInnerHTML` for descriptions. On save, validate against `validateTaskDescriptionJson` server-side (Task 3).
- [ ] Paste-to-upload: intercept the editor paste event; for image blobs, upload to `POST /api/attachments` (multipart, Task 8b/10), receive `{ url, key }`, and insert `<img src="{signedUrl}">` replacing the pasted blob.
- [ ] Accessibility (transcribe verbatim): editor container `role="textbox"` `aria-multiline="true"` `aria-label="Task description editor"`; toolbar `role="toolbar"` `aria-label="Text formatting"`; toggle buttons expose `aria-pressed`; `⌘B`/`⌘I`/`⌘U` formatting shortcuts work and are not overridden; Tab enters the editor, Escape exits to the last focused element outside it.
**Schema / Interfaces:**
```ts
export interface RichTextEditorProps {
  value: unknown;                 // Tiptap JSON document
  onChange: (doc: unknown) => void;
  editable?: boolean;
  locale: 'he-IL' | 'en-US';
  maxChars?: number;              // default 50_000
}
```
**Acceptance:**
- [ ] Character count enforces the 50,000 limit; typing past it is blocked.
- [ ] Pasting an image uploads it and inserts an `<img>` with the returned signed URL.
- [ ] Toolbar toggles report `aria-pressed`; `⌘B`/`⌘I`/`⌘U` toggle bold/italic/underline.
- [ ] In `he-IL` the editor defaults to RTL and the per-paragraph toggle persists `dir`.

### Task 9: Correspondence stream + comment composer (client sanitize)
**Blocks:** 12  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/components/tasks/CorrespondenceStream.tsx`
- Create: `apps/zync-app/src/components/tasks/MessageItem.tsx`
- Create: `apps/zync-app/src/components/tasks/CommentComposer.tsx`
- Create: `apps/zync-app/src/components/tasks/useTaskMessages.ts`
**Steps:**
- [ ] `useTaskMessages(taskId)` — TanStack Query against `GET /api/tasks/:id/messages`; mutation for `POST` and `DELETE`.
- [ ] Render comments and system messages interleaved, sorted by `createdAt` (system messages styled distinctly, e.g. muted with an icon; no author avatar). Show author `Avatar`, name, relative timestamp. Empty state via `EmptyState`.
- [ ] Comment HTML render: pass stored `content` through **DOMPurify** before `dangerouslySetInnerHTML` (defense-in-depth on top of the server allowlist — same pattern as `kb-article-editor`). System message `content` is plain text (no HTML render).
- [ ] Composer uses a compact `RichTextEditor` instance; serialize to HTML on send; optimistic append with `status: 'pending'` then replace with the server `TaskMessage` on success; revert + `toast` on error.
- [ ] Author-owned comments show a delete affordance (`tasks:write`); deleting renders the "deleted" placeholder.
- [ ] `aria-live="polite"` region announces newly arriving messages for screen readers; honor `prefers-reduced-motion` for append animations.
**Schema / Interfaces:**
```ts
export interface PendingMessage extends Omit<TaskMessage, 'id'> { id: string; status: 'pending' | 'sent' | 'error'; }
export function useTaskMessages(taskId: string): {
  messages: TaskMessage[];
  sendComment: (contentHtml: string, attachmentIds?: string[]) => void; // optimistic
  deleteComment: (messageId: string) => void;
};
```
**Acceptance:**
- [ ] Comment HTML is DOMPurify-sanitized client-side before render (a stored `<img onerror>` payload does not execute).
- [ ] System and user messages interleave in `created_at` order.
- [ ] Optimistic send shows `pending` then resolves to the server message; failure reverts with a toast.
- [ ] Author can soft-delete own comment; system messages have no delete control.

### Task 10: Attachments — upload API, R2 storage, chips, PDF viewer
**Blocks:** 12  ·  **Blocked by:** 2, 7
**Files:**
- Create: `apps/zync-api/src/routes/attachments.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
- Create: `apps/zync-app/src/components/tasks/AttachmentChip.tsx`
- Create: `apps/zync-app/src/components/tasks/PdfViewerModal.tsx`
- Create: `apps/zync-app/src/components/tasks/useAttachmentUpload.ts`
**Steps:**
- [ ] `POST /api/attachments` (this plan owns this route; `unified-attachments` is a later wave that depends on this). `authMiddleware` + `requirePermission('tasks:write')`. Accept multipart upload. Enforce max size 25MB and an allowed-MIME allowlist (images, PDFs, Office docs, archives; per-tenant configurable). Store in `STORAGE` (R2) under key `{tenantId}/tasks/{taskId}/{uuid}-{filename}`. Insert a `task_message_attachments` row (initially unparented / parented to a draft message id, re-parented when the comment is posted in Task 4). Return `{ id, url, key, filename, sizeBytes, mimeType }` with a 1h-TTL signed URL.
- [ ] `DELETE /api/attachments/:id` — `requirePermission('tasks:write')`; verify ownership/tenant; delete the R2 object by `r2_key` and remove the row.
- [ ] Client `useAttachmentUpload` — multipart POST with progress; returns attachment ids to pass into `sendComment`.
- [ ] `AttachmentChip` — filename, human-readable size, download link; for `application/pdf` show a thumbnail and open `PdfViewerModal` (react-pdf) on click. Re-sign URL on open if expired.
- [ ] `PdfViewerModal` — react-pdf client-side rendering inside a `Dialog`; keyboard-dismissible (Escape), focus-trapped, `prefers-reduced-motion` respected.
**Schema / Interfaces:**
```ts
// POST /api/attachments (multipart: file, taskId)
//   → { id: string; url: string; key: string; filename: string; sizeBytes: number; mimeType: string }
// DELETE /api/attachments/:id → 204
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const R2_KEY = (tenantId: string, taskId: string, uuid: string, filename: string) => `${tenantId}/tasks/${taskId}/${uuid}-${filename}`;
export function useAttachmentUpload(taskId: string): {
  upload: (file: File) => Promise<{ id: string; url: string; mimeType: string }>;
  progress: number;
};
```
**Acceptance:**
- [ ] Files over 25MB and disallowed MIME types are rejected with 400.
- [ ] Uploaded object lands in R2 at `{tenantId}/tasks/{taskId}/{uuid}-{filename}`; signed URL serves it.
- [ ] PDF chips open the react-pdf viewer; other types show a download link.
- [ ] Deleting an attachment removes both the R2 object and the DB row.

### Task 11: WebSocket client + store/cache integration + optimistic updates
**Blocks:** —  ·  **Blocked by:** 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/realtime/useRealtime.ts`
- Create: `apps/zync-app/src/realtime/realtimeClient.ts`
- Modify: `apps/zync-app/src/pages/tasks/TaskDetailPage.tsx` / board store wiring
**Steps:**
- [ ] `realtimeClient` — open `wss://<api-host>/api/ws/{tenantId}` on app mount (tenant from session); auto-reconnect with backoff; parse incoming `RealtimeEvent`.
- [ ] On `task.updated` / `task.status_changed`: patch the TanStack Query cache for the task and the board's Zustand store. On `task.message`: append/replace in the `useTaskMessages` cache for that task. On `notification.new`: push into the notification store (shared DO connection per comms spec).
- [ ] `useRealtime(taskId?)` — subscribe a component to task-level events while a detail view is open; board-level subscription covers all loaded tasks.
- [ ] Optimistic updates (transcribe spec): status drag/drop updates the Zustand store immediately → background `PATCH /api/tasks/:id` → on error revert + `toast`. Message send appends optimistically with `status: 'pending'` → replace with server message on success (implemented in Task 9; this task ensures the WS-delivered echo dedupes against the optimistic entry).
**Schema / Interfaces:**
```ts
export function useRealtime(taskId?: string): { connected: boolean };
// realtimeClient dispatches parsed RealtimeEvent to registered handlers keyed by event.type
```
**Acceptance:**
- [ ] With the detail open in two browsers (same tenant), a comment in one appears live in the other.
- [ ] A status change broadcasts and updates the board card without a manual refresh.
- [ ] Dropped/closed connections auto-reconnect; the optimistic echo does not duplicate the WS-delivered message.

### Task 12: Audit log interleaved into the stream + activity rendering
**Blocks:** —  ·  **Blocked by:** 7, 9, 10
**Files:**
- Modify: `apps/zync-app/src/components/tasks/CorrespondenceStream.tsx`
- Create: `apps/zync-app/src/components/tasks/useTaskAudit.ts`
**Steps:**
- [ ] `useTaskAudit(taskId)` — fetch `GET /api/tasks/:id/audit`. (Note: per spec, status/assignee/priority/due-date changes already surface as `message_type='system'` rows in the stream; the audit endpoint is the durable record. Render audit-derived activity inline, sorted with comments/system messages by `created_at` — a single chronological activity feed, not a separate tab.)
- [ ] Map audit `action` values to localized phrasing (i18n keys) with `previous_value`/`new_value` interpolation; fall back to the stored system-message `content` for display where present.
- [ ] Ensure RTL and reduced-motion are respected in the merged feed.
**Schema / Interfaces:**
```ts
export function useTaskAudit(taskId: string): { entries: TaskAuditRow[]; isLoading: boolean };
```
**Acceptance:**
- [ ] Audit-derived activity and comments render in one chronological feed ordered by `created_at` (no separate audit tab).
- [ ] Each activity line shows actor + localized action description; RTL renders correctly.
