# Shape: My Work — follow-up interaction redesign
# resolved-cell: full·dynamic  regime: high
## Created: 2026-06-15
## Branch: main
## Worktree: (none — authored on main; /implement should create one via /worktree)
<!-- ← Back to brief (current-plan-brief.md) — the plain-language owner brief; this full plan is for the executing agents -->

> **IMPLEMENTED 2026-06-16 on branch `mywork-followup-interaction` (BASE `866f6374`).** All 4 items COMPLETE. Built via /implement: seam (serial) + 3 file-disjoint packets (A navigation, B activity write-path, C row cluster). Gate: tsc 0 · next build 0 (1772 routes, PPR) · 19 hrefFor/transformTask tests pass · no stubs · all new symbols wired · onQuickLog fully removed. Cross-reference for `/verify`.
> **Convergence:** authored by a /shape dynamic workflow — 5 code-tracing discovery agents + 4 CRM competitive-research agents + 4 per-sibling ideation agents, then a round-2 adversarial pass (5 design lenses + a fresh independent adjudicator + a completeness critic). The adjudicator's **10 must-fixes** and the critic's **11 findings (2 CRITICAL)** are all folded below. Adjudicator verdict pre-fold: `converged=false`; this document is the post-fold revision.

---

## The problem (owner's words, verified in code)

**A — Functional, NOT cosmetic.** Clicking a follow-up *sometimes loads nothing at all*; some rows/elements are clickable and some are not — inconsistent.
**B — Wrong destination.** When it does navigate, it "redirects to something" confusing and slow.
**C — No clear way to mark a follow-up done.** Is the user supposed to "log activity" to record it? That feels unintuitive, and it sits in — and feels like part of — the snooze options.

## Root cause (single sentence, code-verified)

`hrefFor()` (`lib/work/actions.ts:159-176`) routes **every task row** to `/tasks?task=<id>`, but `/tasks` has **no per-task route** — the panel only opens if that id is already in the **capped 500-row, status/position-ordered board**; if it isn't (an overdue follow-up that sorts late, or one filtered out), the deep-link effect (`app/tasks/TasksClient.tsx:129-139`) **silently strips the param and drops the user on the bare board** = "nothing happened." Meanwhile **activity** rows route straight to the entity record (`/companies|/people`). Same-looking rows, two code paths, one broken — *that is the inconsistency*. Separately, completion is split: the leading circle does a silent `status:'done'` (records nothing); "Log activity" creates a new activity but **leaves the follow-up task open**, and it lives **inside the snooze `⋯` menu**. The defect is duplicated on the dashboard "Your work" widget (same `hrefFor` seam).

## What good looks like (industry standard — Pipedrive / HubSpot / Salesforce / Close / Things / Todoist / Superhuman)

All seven converge on the same answers (citations in the research appendix):
- **One consistent click target** that lands on the record the row is *about* — identical for every row type, never a generic list.
- **One-click, reversible complete** — a leading control, an Undo always available (Todoist toast, Superhuman Z, Things re-toggle, Close).
- **Completing a touch follow-up captures the outcome** — Close auto-creates the activity on completion; Salesforce makes "Log a Call" *be* the completion; HubSpot's "log first, complete second." Optional/offered, never a forced wall, never buried in a defer menu.
- **Completing offers the next step** — Pipedrive's conditional "schedule next" pop-up, HubSpot's follow-up toggle, Salesforce's "Create Follow-Up Task." "Always have a next step," opting out is the deliberate act.
- **Done and Snooze/Reschedule are physically separate controls** — never the same button, never the same menu.

---

## Cross-sibling build order & file ownership (seam resolution — MANDATORY for /implement)

The adversarial pass found four shared row files were co-owned (two-owners-one-edit). Resolution (the single source of truth — `/implement` MUST honor it):

| File | Owner | What | Note |
|---|---|---|---|
| `lib/work/actions.ts` | **shared, disjoint hunks** | Item 1 rewrites the `hrefFor` task branch (`:160-164`); Item 2 ADDS a net-new pure `outcomePrefill()` helper | Disjoint functions — apply as separate hunks |
| `lib/homeQueries.ts` | **Item 1** | `transformTask` person-entity emission | NEW to Item 1's scope (CRITICAL #1 fix) |
| `contexts/ActivityModalContext.tsx` | **Item 2** | adds optional `sourceTaskId` field | **Lands FIRST** — prerequisite for Item 3's handler cleanup |
| `app/work/WorkSnoozeMenu.tsx` | **Item 3** ONLY | removes "Log activity" + `onQuickLog` prop | Item 2 only VERIFIES this happened |
| `app/work/WorkRow.tsx` | **shared, sub-region split / strictly serial** | circle glyph + busy-inert `<Link>` = **Item 4**; `onQuickLog` prop drop = **Item 3**; stale-comment deletion = **Item 4** (first toucher); the `hrefFor` VALUE change is in `actions.ts`, not here | Edit serially to avoid collision |
| `app/work/WorkKeyboardShortcuts.tsx` | **Item 3** | retire the `e` (log) verb + its cheat-sheet row | Items 2 & 4 VERIFY the copy, do not edit |
| `app/work/WorkClient.tsx` | **shared, coordinated** | Item 2 owns the completion success-toast (Undo + Add-outcome), the new `openActivityModal({...outcomePrefill, sourceTaskId})` call, and DELETING `handleQuickLog`/`onKbQuickLog`; Item 3 drops the `onQuickLog` pass-through | Single fate: `handleQuickLog`/`onKbQuickLog` are **deleted**; outcome opens via the new call, not the old handler |
| `app/work/useWorkActions.ts` *(NEW, reserved-shared)* | **Item 2 extracts** | the optimistic complete/snooze/outcome/Undo lifecycle — today duplicated in `WorkClient.runRemoval` + `HomeDashboard.runWorkRemoval` — hoisted into ONE `'use client'` hook both surfaces consume (takes the surface's active SWR key as a param: `/api/work?scope=` for /work, the home-tasks key for the widget; shares the `isWorkCacheKey` family revalidation + the toast) | The single source of truth for row ACTIONS |
| `app/work/WorkRow.tsx` (variant) | **Item 4** | add `variant?: 'full' \| 'compact'` so the dashboard widget renders the SAME row component, denser | The single source of truth for row PRESENTATION |
| `app/components/home/HomeDashboard.tsx` (`renderYourWork`) | **Item 4** | **DELETE** the hand-rolled row markup + the duplicate `runWorkRemoval`/`handleWork*` handlers; render `<WorkRow variant="compact">` driven by the shared `useWorkActions` hook | Single source of truth (owner Decision 5) — the widget is now a *rendering* of the My Work row, not a second implementation |

**Build order:** (1) Item 2 lands the seam additions (`outcomePrefill` + `sourceTaskId`) AND extracts the shared `useWorkActions` hook from `WorkClient.runRemoval` → (2) Item 1 (drill-through, independent) → (3) Item 3 (de-conflate, after the context field exists) → (4) Item 4 (legibility/states/Undo, the WorkRow `compact` variant, and the dashboard single-source refactor — touches WorkRow last). The `e`-verb retirement threads `onQuickLog` through 5 files — remove it **atomically** or tsc breaks.

### Single-source-of-truth architecture (owner Decision 5)

The dashboard "Your work" widget today is a **second, hand-rolled row implementation** (`HomeDashboard.renderYourWork`, its own markup + its own `runWorkRemoval`/`handleWork*` handlers) that merely shares the pure `lib/work/actions.ts` seam. The owner directive: it must be a **differently-rendered view of the My Work panel — one source of truth, not two surfaces kept in parity.** The refactor:

- **Row presentation = ONE component.** `WorkRow` gains a `variant: 'full' | 'compact'`; the widget renders `<WorkRow variant="compact">` instead of bespoke markup. So every legibility/state/Undo/busy-inert fix lands once and both surfaces show it — there is nothing left to "propagate."
- **Row actions = ONE hook.** The optimistic complete/snooze/outcome/Undo lifecycle (currently duplicated in `WorkClient.runRemoval` and `HomeDashboard.runWorkRemoval`) is hoisted into a new `'use client'` `useWorkActions(activeKey)` hook. Both surfaces call it; the only per-surface difference is the **active SWR key** passed in (the two surfaces fetch from *different* sources — `/api/work?scope=` vs `/api/home/tasks` — but both already live in the `isWorkCacheKey` family, so the shared family-revalidation is identical). The widget keeps its own DATA fetch (limited home-tasks set) but no longer owns row markup or mutation logic.
- **Net effect:** Items 1, 2, 3 stop carrying any "also patch the widget" burden — the widget inherits automatically because it renders the shared component + hook. This is *simpler and safer* than the parity approach the pre-fold plan assumed.
- **Implementer guards:** `WorkRow` must not depend on `/work`-only context (the keyboard layer, the `/work` `<SWRConfig>`); the widget supplies the toast + activity-modal context (both available inside the authenticated shell). `view_only` (readOnly) and the compact density are props, not surface-specific branches.

## Companion-surface decisions (Phase 1d-bis)

- **Access & roles — APPLIES, no new keys.** Completion rides `tasks.edit`; outcome logging rides `activities.create`; next-step rides `tasks.create`; reassign rides the existing manager-only `canReassign = canSeeTeam` gate (and `tasks.assign` if wired to a real mutation). `view_only` stays drill-through-only at all three layers (page `readOnly`, WorkRow branch, keyboard early-return) + API 403s independently. **Do NOT invent `tasks.complete`/`activities.log`** (would force a migration + `auth_version` bump + editor row).
- **Tier / limits — N/A.** No new gated capability or quota.
- **Marketing copy — N/A.** Internal UX repair, no public surface.
- **AI tools — N/A.** No agent/tool surface touched (AI follow-up *creation* already sets `related_company_id`/`related_person_id`; it benefits from the fix for free).
- **Docs — APPLIES (light).** The `/docs/work` "My Work" help article gains a short "completing a follow-up & logging the outcome" section. One docs item under Item 2.
- **Design playbook — APPLIES.** All UI work binds `docs/design/APP_REFINEMENT_PLAYBOOK.md` + the `/work` design-of-record `docs/design/work-surfaces/PLAN.md` (disclosure depth ≤2, one cobalt accent per row, control-budget/TTFV clause, anti-AI-slop, touch ≥44px, hydration-safe string-parse dates, reuse `SlidePanel`/`Dropdown` primitives — never a new modal).

---

## Item 1 — Reliable + consistent drill-through (FUNCTIONAL FIX — owner problems A + B)

> **STATUS: COMPLETE** — `lib/work/actions.ts` (hrefFor entity-precedence), `lib/homeQueries.ts` (transformTask person entity), `components/tasks/EntityTasksSection.tsx` + `app/projects/[id]/sections/ProjectDetailClient.tsx` (?task= auto-open + fetch-by-id), `app/tasks/TasksClient.tsx` (fetch-by-id fallback + 404 toast), `lib/work/__tests__/actions.test.ts` (+person-only fixture). Host pages verified PPR-safe, no edit needed.

**Description.** Make every row click land on the record it names and **actually load**, identically for tasks and activities. The task branch of `hrefFor` is rewritten: precedence is **(c) `sanitizeInternalLink(sourceLink)`** (converted tasks keep their action-surface deep-link — UNCHANGED, stays first) → **(a/b) the row's entity** (`/companies/<id>?task=<id>` | `/people/<id>?task=<id>` | `/projects/<id>?task=<id>`) → **(d) `/tasks?task=<id>`** only when there is no entity. The entity host reads `?task=` and opens the existing `TaskDetailPanel` in-context (depth-2: row → in-context panel). The `/tasks?task=` fallback is made **self-sufficient** — when the id isn't in the loaded board, fetch it by id and open the panel anyway (no more silent strip). One fix in the shared seam heals `/work`, the dashboard widget, AND keyboard `o`/Enter.

**CRITICAL #1 fold (adjudicator must-fix #1 / critic CRITICAL #1):** the new `case 'person'` branch is **dead code today** because the work-queue source `transformTask` (`lib/homeQueries.ts:564-578`) only ever emits `company` / `project` / `null` — `related_person_id` is consumed as `contactName`, never as the routing entity. So a "follow up with Jane Doe" (person-only) row gets `entity=null` → falls to the board → the exact pain B. **Fix: add `lib/homeQueries.ts` to this item and amend `transformTask`** so when company is absent but person is present it emits `entity={type:'person', id, name, contactName:null}` (fallback order company → project → person → null).

**Project-host fold (adjudicator must-fix #3 / critic CRITICAL-adjacent — RESOLVED, Decision 4: full parity).** `EntityTasksSection` has **no `project` scope** and is **not mounted on the project detail page** — routing a project task to `/projects/<id>?task=<id>` would reintroduce "nothing loads." **Fix:** give `ProjectDetailClient.tsx` its own `?task=` auto-open effect (it already imports `TaskDetailPanel` + has `handleTaskClick` at `:590`), reading `?task=` → find in its board tasks → open, else fetch-by-id → open → strip. Full parity across company/person/project — a project follow-up opens in context on the project (industry standard).

**Shape-mapping fold (critic MED #9):** the by-id `GET /api/tasks/[id]` returns a rich shape; the board feeds `BoardTask → TaskDetailTask`. Pin the field mapping (the GET payload is a superset; pass the **full** payload, not a 2-field stub) so the fallback can't itself "load nothing." Panel opens over the board as-is; closing returns to the board.

**Acceptance.**
- A follow-up/plain TASK that carries an entity drills to that record (`/companies/<id>` | `/people/<id>` | `/projects/<id>`) — the SAME mental model as an activity row — never the generic `/tasks` board.
- **A follow-up linked to a PERSON only** (`related_person_id`, no company/project) drills to `/people/<id>?task=<id>` — not the board. (`transformTask` now emits a person entity; covered by a new test fixture.)
- Landing on `/companies/<id>?task=<id>` (or person/project) opens the task in-context: the host reads `?task=`, opens `TaskDetailPanel` from its fetched list, and **if the id isn't in that slice, fetches `GET /api/tasks/<id>` and opens it anyway**, then strips the param. No silent no-op.
- A converted task with a valid `source_link` still drills to its sanitized action surface (`sanitizeInternalLink` precedence stays FIRST) — UNCHANGED.
- A task with NO entity routes to `/tasks?task=<id>`, and `TasksClient` opens it **even when absent from the 500-row board** (fetch-by-id fallback); on a real 404 it toasts "Task not found" instead of dropping the user on a bare board.
- The by-id `GET /api/tasks/<id>` payload maps cleanly onto `TaskDetailTask` (full payload passed, field-name parity verified) so the fallback reliably renders.
- Activity rows still route to their entity page exactly as before — no regression (asserted via the extended `actions.test.ts`).
- The fix lands in `lib/work/actions.hrefFor` so `/work`, the dashboard "Your work" widget, AND keyboard `o`/Enter drill consistently — verified on all three.
- The entity-host `?task=` auto-open uses `useSearchParams()` inside the existing page `<Suspense>` (no `force-dynamic`, no `next/dynamic ssr:true`); `TaskDetailPanel` stays `dynamic ssr:false`. Build stays PPR-clean.
- `GET /api/tasks/[id]` enforces `.eq('organization_id', auth)` and 404s a cross-tenant/foreign id — the panel never opens another tenant's task.

**Files.**
- `lib/work/actions.ts` *(reserved-shared)* — `hrefFor` task branch: precedence sourceLink → entity (company/person/project) → `/tasks?task=`. Disjoint from Item 2's hunk.
- `lib/homeQueries.ts` *(reserved-shared)* — `transformTask` emits a `person` entity when company absent + person present. **(CRITICAL #1)**
- `components/tasks/EntityTasksSection.tsx` *(reserved-shared)* — net-new `?task=` auto-open effect (mirror the proven `TasksClient` pattern: open from fetched list, else fetch by id, then strip).
- `app/tasks/TasksClient.tsx` *(owned)* — deep-link effect (`:129-139`) gains fetch-by-id fallback + 404 toast + the `GET → TaskDetailTask` shape mapping.
- `app/projects/[id]/sections/ProjectDetailClient.tsx` *(owned)* — own `?task=` auto-open effect (project parity). **(must-fix #3)**
- `app/companies/[id]/sections/CompanyDetailClient.tsx`, `app/people/[id]/page.tsx` *(owned, likely VERIFY-only)* — confirm `EntityTasksSection` mounts inside the page `<Suspense>` so the new `useSearchParams` is PPR-safe; they may need no edit (the effect self-reads inside the section).
- `app/components/home/HomeDashboard.tsx` — inherits the corrected `hrefFor` automatically once Item 4 renders the widget through the shared `WorkRow` (single source, Decision 5); no separate Item-1 edit.
- `lib/work/__tests__/actions.test.ts` *(reserved-shared)* — extend: follow-up-with-company, **follow-up-with-person-only**, sourceLink precedence, no-entity fallback, activity unchanged.

**Appetite.** Small/2-day. One function (`hrefFor`) + one `transformTask` amendment + one reusable auto-open effect (copied pattern, applied to 1 shared section + the project host) + one fetch-by-id fallback reusing the existing org-scoped `GET /api/tasks/[id]`. No new endpoint/RPC/migration/RBAC key. If the project-host effect balloons, fall back to routing project tasks to `/tasks?task=` (still self-sufficient) and document the divergence.

**Permission scope.** Rides existing keys only. By-id source is `withAuthCached` + `.eq('organization_id', auth)` (org from auth, never the id param). Entity landing pages carry their own `companies.view`/`people.view`/`projects.view` gates. `view_only` keeps drill-through-only; the corrected target works for them unchanged. No `auth_version` bump.

**Edge cases (KEC).**
- *boundary/empty/over-limit* — **ACCEPT.** Beyond-500-cap & filtered-out tasks: cases a/b/c bypass the board; case d gains fetch-by-id. `entity=null` → `/tasks?task=`. Missing `item.id` → guard returns `/tasks`.
- *failure-mode* — **ACCEPT.** By-id 404/500 (deleted/foreign/archived) → "Task not found" toast + strip, never a wedged blank.
- *race/concurrency* — **ACCEPT.** While busy, the body `<Link>` is inert (owned by Item 4) so a click can't race the optimistic removal; `?task=` strips immediately after opening so back-nav won't reopen a stale panel.
- *permission/role* — **ACCEPT.** `view_only` drill-through unchanged; by-id source org-scoped; destination page re-checks its own `*.view`.
- *related-entity/referential* — **ACCEPT.** `item.entity` populated by `transformTask` from org-verified FKs; deleted related entity → destination page 404s its own way (strictly better than the old board dead-end).
- *input-validity* — **ACCEPT.** `source_link` stays routed through `sanitizeInternalLink` (rejects external/poisoned); `?task=` is a server-generated id used only for the org-scoped by-id GET; `entity.type` is a known enum.

**Parallel-safety.** Touches `lib/work/actions.ts` (disjoint hunk from Item 2) and `lib/homeQueries.ts` (sole owner). Entity-host edits are file-disjoint from the other items. WorkRow contributes no edit here.

**Cohesion verdict.** EXTEND — no net-new entity/component. `hrefFor` extended in place; the in-context open EXTENDS the existing `EntityTasksSection`/`TaskDetailPanel`; the by-id source REUSES the existing `GET /api/tasks/[id]`. **Ripple:** MUST-PROPAGATE to WorkRow body, dashboard widget, keyboard open (shared seam) + every entity host `hrefFor` can target (company/person/project). VERIFY-UNAFFECTED: activity drill-through, source_link branch, PPR build, hydration-safety.

**Advisory NOTE (downstream, not plan-blocking).** `hrefFor`'s switch covers person/project/company(default) — `opportunity` cannot occur as a task entity (tasks carry company/project/person/null only), so no opportunity case is needed.

---

## Item 2 — Complete-with-outcome (the unified "done" flow — owner problem C)

> **STATUS: COMPLETE** — outcome write-path persisted with server-side validation: `lib/activities/validate.ts` (surfaces `candidateTaskId`, never auto-stamps — closes the AI-path cross-tenant exposure), `app/api/activities/route.ts` (extract+UUID-validate+org-verify+stamp clean `metadata.task_id`), `lib/activities/logInteraction.ts` + `createEvent.ts` (persist metadata), `components/activity/UnifiedActivityModal.tsx` (send metadata + call_outcome surfaced + prominent schedule-next default + mutationFetch). Toast Undo+Add-outcome + outcomePrefill live in the seam/`useWorkActions` (Item 4's hook). No migration (column+index exist).

**Description.** Replace the split (silent circle + buried "Log activity" that leaves the task open) with ONE coherent model: the leading circle still completes in a **single tap** (TTFV=0, instant optimistic removal, never a forced wall). That tap fires a success **toast that carries two actions: Undo (always) and — for touch/follow-up rows — "Add outcome."** "Add outcome" opens the EXISTING activity composer pre-bound to the just-completed row's entity + a sensible verb + subject + the `sourceTaskId`; on submit the new activity is **server-stamped** with `metadata.task_id` so it reads as the outcome of that follow-up on the entity timeline + the `TaskDetailPanel` Activity tab. The composer's existing Follow-up chip (`createTask is_follow_up=true`) schedules the next touch — letting the `recompute_next_follow_up` trigger own the cache (app code writes `next_follow_up` NOWHERE). Completion stays `PATCH /api/tasks/[id] {status:'done'}` so the done-transition side effects (automation, webhook, auto system-breadcrumb, trigger) all keep firing.

**CRITICAL #2 fold (adjudicator must-fix #2 / critic CRITICAL #2).** The "reuses the existing read filter, no new column" claim is only half true: the `metadata->>task_id` **read** filter exists, but the user-facing **write** path strips `metadata` at three layers (`UnifiedActivityModal` POST body, `lib/activities/validate.ts` `NormalizedActivityInsert`, `lib/activities/logInteraction.ts` `insertRow` allowlist). As written the link silently no-ops. **Fix — extend the write path:** (a) add `metadata?` to `NormalizedActivityInsert` + `LogInteractionPayload` and persist it in `logInteraction.insertRow` (+ `createEvent` insert); (b) `UnifiedActivityModal` includes `metadata:{task_id: sourceTaskId}` in the POST body when `sourceTaskId` is set; (c) **SECURITY:** the activities POST route does **NOT** trust a client `metadata` blob — extract only `task_id`, UUID-validate it, verify it references a task in the caller's org (the `assertRelatedFksOwned` pattern), and stamp a clean server-side `metadata:{task_id}`. The column + `(metadata->>'task_id')` index already exist (migration `20260523330000`) → **no migration**.

**Undo fold (critic HIGH #6).** Reversible completion is a unanimous table-stake; today success = the row is simply gone. The completion toast gains an **Undo** action (re-PATCH `status` to the prior value — which clears `completed_at`, route `:191` — + optimistic re-insert). The API precedent exists (the DELETE path already ships a `restoreToken` undo, `:566`). Toast supports `action`+`duration`; a **second action slot** is a small `Toast.tsx` extension (Undo + Add-outcome). See Decision 1.

**Schedule-next fold (critic MED #5).** Don't downgrade the strongest research pattern to a passive chip. For touch-type completions the composer's "set a follow-up" is **prominently defaulted** (Pipedrive/HubSpot "opting out is the deliberate act"); optionally read the entity's `next_follow_up` to nudge only when there's no next step. See Decision 2.

**Disposition fold (critic MED #4).** When the outcome verb resolves to `call`, surface the composer's EXISTING structured `call_outcome` select (Connected/Voicemail/No Answer/Busy/Wrong Number, `:77-84`) — closes the structured-disposition table-stake with zero new UI.

**Activity-row fold (critic MED #8).** Activity rows also flow through `handleComplete`. Define it explicitly: an activity-row completion gets a **plain success toast + Undo, NO "Add outcome"** (the activity *is* the record) — a documented, deliberate choice so the two row types aren't an accidental inconsistency.

**Verb-default honesty fold (adjudicator must-fix #8).** A follow-up TASK carries no `activityType`, so "default to call" never fires for the headline case. Reword: "defaults the verb to the row's activity type when the row is an activity (call→call, meeting→meeting), and to **note** for a plain/follow-up task."

**Handler-fate fold (adjudicator must-fix #7).** Single fate: Item 2 introduces a NEW `openActivityModal({...outcomePrefill(item), sourceTaskId})` call in the `runRemoval` success branch; the +New FAB keeps its own call; the OLD `handleQuickLog` (quickMode, no sourceTaskId) and `onKbQuickLog` are **DELETED**. No dead handler, no second composer-open path.

**CSRF fold (critic LOW #11).** The composer POST is a raw `fetch`; when adding the `metadata` flow, migrate it to `mutationFetch` (or confirm the CSRF interceptor is installed before the toast action can fire).

**Acceptance.**
- One tap on the Complete circle removes the row INSTANTLY (optimistic) for both a follow-up task and an activity row; on failure the row reverts + an error toast — no forced dialog ever blocks completion (TTFV=0).
- Completing a follow-up TASK still issues `PATCH /api/tasks/[id] {status:'done'}` so the done-transition side effects fire unchanged (system breadcrumb, `task.completed` automation, webhook, `recompute_next_follow_up`) — `next_follow_up` written by app code NOWHERE; verified the completed follow-up drops from the entity's `next_follow_up` MIN.
- The COMPLETE success toast carries **Undo** (re-PATCH to prior status + optimistic re-insert) and, for touch/follow-up rows, **Add outcome**; snooze/reschedule toasts carry neither (defer ≠ done). The composer is never auto-opened.
- **Round-trip (CRITICAL #2):** complete a follow-up → Add outcome → submit → the new activity is fetchable via `GET /api/activities?task_id=<id>` and renders on the `TaskDetailPanel` Activity tab + the entity timeline. The `task_id` is server-validated (UUID + org-owned) and stamped server-side; a client `metadata` blob is never trusted wholesale. No migration (column + index already exist).
- "Add outcome" pre-binds the composer to the completed row's entity, defaults the verb to the row's activity type for activities (call→call, meeting→meeting) and to **note** for a plain/follow-up task, and pre-fills a sentence-case subject; when the verb is `call` the existing `call_outcome` disposition select is surfaced.
- Scheduling the next follow-up goes through the composer's existing Follow-up chip (`createTask is_follow_up=true`) — **prominently defaulted for touch completions**; the trigger recomputes `next_follow_up`; the new follow-up appears after `mutate(isWorkCacheKey)` with no manual cache write and no double-count (real `tasks` row).
- An ACTIVITY-row completion shows a plain success toast + Undo and **no "Add outcome"** (deliberate, documented).
- `handleQuickLog`/`onKbQuickLog` are deleted; the only composer-open paths are the post-complete toast action and the +New FAB.
- Both `/work` AND the dashboard widget show identical completion + outcome-offer behavior (built via the shared `outcomePrefill` + `sourceTaskId`).
- `view_only` sees no circle → no toast offer; keyboard verbs suppressed after the `readOnly` early-return; `POST /api/activities` 403s for `activities.create='none'`.
- The composer POST migrates to `mutationFetch` (or the CSRF interceptor is confirmed present) on the path now carrying `metadata`.
- A short docs section is added to the `/docs/work` "My Work" article covering complete → log outcome → schedule next.
- All new rendering stays hydration-safe (string-parse, no Date/locale); PPR rules intact (composer stays `dynamic ssr:false` via `GlobalActivityModal`).

**Files.**
- `app/work/WorkClient.tsx` *(shared, coordinated)* — completion success toast (Undo + Add-outcome via `addToast`), the new `openActivityModal({...outcomePrefill, sourceTaskId})` call, DELETE `handleQuickLog`/`onKbQuickLog`, activity-row branch (plain toast), announce() copy. **`runRemoval` is extracted into the shared `useWorkActions` hook (Decision 5) and `WorkClient` consumes it** (passing its `/api/work?scope=` key) rather than owning the lifecycle inline.
- `app/work/useWorkActions.ts` *(NEW, reserved-shared — Item 2 extracts)* — the `'use client'` hook owning the optimistic complete/snooze/outcome/Undo lifecycle + the toast + `isWorkCacheKey` family revalidation; parameterized by the surface's active SWR key. Both `/work` and the dashboard widget consume it (single source of truth for row actions).
- `lib/work/actions.ts` *(reserved-shared, disjoint hunk)* — net-new pure `outcomePrefill(item): ActivityModalOptions` (entity→prefill + verb + subject + `sourceTaskId`). `completeRequest` contract UNCHANGED.
- `contexts/ActivityModalContext.tsx` *(reserved-shared — Item 2 owns, lands FIRST)* — add optional `sourceTaskId`.
- `components/activity/UnifiedActivityModal.tsx` *(reserved-shared)* — on submit, include `metadata:{task_id: sourceTaskId}` when set; surface `call_outcome` for call verb; default Follow-up chip prominently for touch; migrate POST to `mutationFetch`.
- `lib/activities/validate.ts` *(reserved-shared)* — `NormalizedActivityInsert` gains `metadata?`; `validateCreate` copies a validated `metadata.task_id`. **(CRITICAL #2)**
- `lib/activities/logInteraction.ts` *(reserved-shared)* — `LogInteractionPayload` gains `metadata?`; `insertRow` persists it. **(CRITICAL #2)**
- `lib/activities/createEvent.ts` *(reserved-shared)* — meeting path persists `metadata` too.
- `app/api/activities/route.ts` *(reserved-shared)* — POST extracts only `task_id`, UUID-validates, org-verifies, stamps clean server-side. **(CRITICAL #2 security)**
- `components/ui/Toast.tsx` *(owned)* — optional second action slot (Undo + Add-outcome). (See Decision 1.)
- `app/components/home/HomeDashboard.tsx` — NO separate Item-2 edit: the widget inherits the toast (Undo + Add-outcome) automatically once Item 4 renders it through `<WorkRow variant="compact">` + `useWorkActions` (single source of truth, Decision 5). Item 2 only ensures the shared hook carries the toast.
- `app/docs/work/overview/page.tsx` *(owned)* — docs section.
- `lib/work/__tests__/actions.test.ts` *(reserved-shared)* — `outcomePrefill` coverage.

**Appetite.** Medium/~2.5 days (grew from the lite estimate because the write-path extension + Undo + dashboard parity are real). Still no new endpoint/RPC/migration; every capability rides an existing home (composer, action-toast, `completeRequest`, `metadata->>task_id`, `createTask`).

**Permission scope.** Completion = `tasks.edit`; outcome = `activities.create` (`withPermission`, `view_only`='none'→403); next-step = `tasks.create` (`assertRelatedFksOwned` org-verifies FKs). Outcome offer only reachable when the user could complete (`!readOnly`); API 403s independently.

**Edge cases (KEC).**
- *boundary/empty* — **ACCEPT.** `entity=null` completion → toast opens composer with no entity prefill (verb+subject+sourceTaskId only); no-due-date row completes identically; toast actions are buttons (no overflow).
- *failure-mode* — **ACCEPT.** Complete PATCH fail → revert + error toast, no outcome offer. Outcome POST fail (row already gone) → composer inline `FormAlert`; task stays completed (already succeeded), only the log failed — no half-state. `metadata.task_id` is best-effort linkage; a missing link degrades to an unlinked-but-valid activity.
- *race/concurrency* — **ACCEPT.** `busyIds` guards double-complete; complete resolves before the toast renders, so PATCH-done-then-POST-activity is sequential (no ordering race). Two tabs → second PATCH idempotent (side effects guarded by `existingTask.status!=='done'`).
- *permission/role* — **ACCEPT.** `view_only` no circle → no offer; rep completing a manager-owned team task gated by the tasks PATCH ownership clamp; outcome rides `activities.create`.
- *related-entity/referential* — **ACCEPT.** Outcome links via `metadata.task_id` (JSONB, not FK) so a later task delete can't dangle; next follow-up is a real org-verified `is_follow_up` task; `next_follow_up` trigger-owned, zero app cache writes.
- *input-validity* — **ACCEPT.** `task_id` server-extracted/UUID-validated/org-verified, never trusted from a client blob; composer fields keep existing validation; entity ids come from org-scoped `item.entity`.

**Parallel-safety.** `lib/work/actions.ts` disjoint hunk (vs Item 1). `ActivityModalContext.tsx` landed FIRST. The activity write-path files (`validate.ts`/`logInteraction.ts`/`createEvent.ts`/`route.ts`) are this item's exclusive scope. WorkClient coordinated with Item 3 (onQuickLog drop).

**Cohesion verdict.** EXTEND the existing composer — do NOT fork. Net-new: one pure `outcomePrefill` helper + one optional `sourceTaskId` field + a `metadata` allowlist extension + a second toast action. PARALLEL-JUSTIFIED: none. **Ripple:** MUST-PROPAGATE to the dashboard widget, the keyboard cheat-sheet/announce copy (via Item 3), the three write-path files + the modal + the POST route. VERIFY-UNAFFECTED: the done-transition side effects (must keep firing on PATCH done), the activity timeline taxonomy/anti-slop (no new outcome hue), the +New FAB compose-then-complete path.

**Advisory NOTE.** The system breadcrumb auto-written on task-done stays — do NOT stack a second activity insert on the PATCH; the user outcome is the SEPARATE, optional composer activity.

---

## Item 3 — De-conflate complete/log from snooze (row action model)

> **STATUS: COMPLETE** — `app/work/WorkSnoozeMenu.tsx` (Log activity removed; defer+delegate only; no empty divider when !canReassign), `app/work/WorkKeyboardShortcuts.tsx` (`e` retired + cheat-sheet row), `app/work/WorkSection.tsx` + `app/work/WorkRow.tsx` (onQuickLog prop dropped), `app/work/WorkClient.tsx` (handleQuickLog/onKbQuickLog deleted; Reassign → task detail panel via the hook). `onQuickLog` = 0 references repo-wide.

**Description.** Make the DO-vs-HAPPENED grammar unambiguous: the leading Complete circle is the single primary "I did this" verb (handing to Item 2's flow), and snooze/reschedule is a clearly-secondary defer family that never co-locates with outcome capture again. **"Log activity" is REMOVED from the `⋯` menu** (the structural cause of "logging feels like snoozing"). The `⋯` becomes a pure defer+delegate menu: Snooze presets + Pick-a-date, then (manager) Reassign. The trailing "Later" hover chip stays. Keyboard `e` (log) is retired; `c`/`x` complete, `s` opens the defer menu. This item is the **single owner** of the final row-action grammar (`WorkSnoozeMenu`, the `onQuickLog` prop drop on WorkRow, `WorkKeyboardShortcuts` + its cheat-sheet/announce copy).

**Reassign fold (critic HIGH #7 — RESOLVED, Decision 3).** `handleReassign` today calls `handleQuickLog` → the activity composer, which has **no assignee control** = a dead manager control. **Repoint Reassign to open the now-reliable `TaskDetailPanel`** (which owns the real assignee mutation) instead of the composer — industry standard (owner is a field on the task record), reuses Item 1's fix, no new picker. No dead control ships.

**KEC fold (adjudicator must-fix #9).** Re-disposition this item's edge axes — three were bare ACCEPT tokens with no backing bullet ("disguised blanks"): set `failure-mode`, `race/concurrency`, `related-entity/referential` to **N/A** (this item adds no async mutation / no entity relationship — it removes a menu item and re-groups a dropdown), and back `boundary/empty/over-limit` with a real bullet.

**Acceptance.**
- The `⋯` overflow contains ONLY defer+delegate: 5 snooze presets, a divider, then Reassign (manager-only). "Log activity" no longer appears on any breakpoint.
- Outcome capture is reachable ONLY through the Complete circle's flow (Item 2) — no peer "Log activity" affordance on the row or in the menu. The DO-vs-HAPPENED split is stated in one line of copy.
- **When `canReassign` is false the menu renders snooze presets + Pick-a-date only, with no trailing divider/empty Reassign slot.** (boundary axis)
- Disclosure depth stays ≤2 (row → circle → complete flow; row → `⋯` → flat defer menu); control budget unchanged/reduced (rep 1 control, manager 2; "Later" a reserved hover slot, no reflow).
- Keyboard grammar consistent: **`e` is retired** from the handler AND the cheat-sheet; `c`/`x` complete, `s` opens the defer menu, `h` snoozes to tomorrow, `o`/Enter open. `SHORTCUTS_FULL` + the announce() strings name only surviving verbs. **(adjudicator must-fix #6 — single owner)**
- **Reassign** opens the task detail panel (with its real assignee control), not the no-op composer (Decision 3). No dead control.
- The dashboard widget renders the same grammar (Snooze + Complete, no Log-activity item) — verified.
- `view_only` unaffected — drill-through only; keyboard suppresses mutating verbs after the `readOnly` early-return.
- `onQuickLog`/`onKbQuickLog` are removed cleanly across all 5 files they thread through (atomic, tsc-clean); no dead handler.

**Files.**
- `app/work/WorkSnoozeMenu.tsx` *(owned)* — remove "Log activity" + `onQuickLog`; menu = defer + delegate; update header doc.
- `app/work/WorkRow.tsx` *(shared — Item 3 owns the `onQuickLog` prop drop only)*.
- `app/work/WorkKeyboardShortcuts.tsx` *(owned)* — retire `e`/`onQuickLog`; remove the cheat-sheet row.
- `app/work/WorkClient.tsx` *(shared, coordinated with Item 2)* — drop `onQuickLog` pass-through; Reassign repoint.
- `app/work/WorkSection.tsx` *(owned)* — drop the `onQuickLog` pass-through.
- `app/components/home/HomeDashboard.tsx` — inherits the de-conflated grammar automatically (the widget renders the shared `WorkRow`/`WorkSnoozeMenu` per Decision 5); no separate Item-3 edit.
- `app/api/tasks/[id]/route.ts` *(VERIFY)* — supports `PATCH {assigned_to}` with `tasks.assign`; Reassign is wired to open the task detail panel (Decision 3 — the panel owns the real assignee mutation), so no new mutation path is added here.

**Appetite.** Small/~1 day, mostly subtractive. Sequence AFTER Item 2 lands `sourceTaskId` (so the handler cleanup reads the final context shape).

**Permission scope.** Zero new keys. Reassign stays manager-gated (`canReassign = canSeeTeam`); the `tasks.assign` route enforcement applies if wired to a real mutation. `view_only` unchanged.

**Edge cases (KEC).**
- *boundary/empty/over-limit* — **ACCEPT.** `canReassign=false` → snooze+Pick-a-date only, no empty divider/Reassign slot.
- *failure-mode* — **N/A.** No async mutation introduced; completion/snooze handlers are owned by other items; this item removes a menu item and re-groups the dropdown.
- *race/concurrency* — **N/A.** Same reason — no new mutation or shared async state.
- *permission/role* — **ACCEPT.** Reassign manager-only; `view_only` suppressed; the API gate is unchanged behind the (repointed) affordance.
- *related-entity/referential* — **N/A.** Consumes `lib/work/actions.ts` read-only; introduces no entity relationship or FK.
- *input-validity* — **N/A.** No new user input field added (menu restructure only).

**Parallel-safety.** Owns `WorkSnoozeMenu`, `WorkKeyboardShortcuts`, `WorkSection`; shares `WorkRow`/`WorkClient`/`HomeDashboard` under the ownership table (serial). `onQuickLog` removal is atomic across the 5 files.

**Cohesion verdict.** EXTEND (subtractive restructure). No net-new. **Ripple:** MUST-PROPAGATE — the `onQuickLog` removal + keyboard grammar across the row component cluster. VERIFY-UNAFFECTED: snooze behavior, `view_only` path.

---

## Item 4 — Affordance discoverability + states (legibility, click feedback, loading/empty/error, a11y, dashboard parity)

> **STATUS: COMPLETE** — `app/work/useWorkActions.ts` (NEW shared hook: optimistic complete/snooze/reassign + Undo+Add-outcome toast, parameterized by activeKey), `app/work/WorkRow.tsx` (variant full|compact, busy-inert Link, rest-legible Complete glyph, stale comments deleted), `app/work/WorkClient.tsx` (consumes hook, real Retry button), `app/components/home/HomeDashboard.tsx` (SINGLE SOURCE OF TRUTH — renderYourWork now `<WorkRow variant="compact">` + `useWorkActions('/api/home/tasks')`, duplicate markup/handlers deleted, ~175 lines removed). Live render/visual pass deferred to /pipeline's /test.

**Description.** Make "this is how you mark it done" unmistakable (the dashed circle currently reads as decorative — the owner didn't realize it completes), kill the "I click and nothing happens" perception with real states, and bring the dashboard "Your work" widget to full parity. Owns: the circle's legibility treatment, the **busy-inert `<Link>`** (pointer-events-none + aria-disabled + removed from tab order while a mutation is in flight), the stale-comment deletion on WorkRow, the loading/empty/error/no-op states, keyboard `c`/`o` discoverability + the live-region announcements (VERIFYING Item 3's grammar), touch ≥44px, motion-safe transitions, focus management.

**Dashboard single-source refactor (owner Decision 5, supersedes critic HIGH #3's "parity" framing).** The widget's row is today a separate hand-rolled block (absolutely-positioned hover-gated cluster, date hidden on touch, non-legible resting circle, non-busy-inert `<Link>`, a duplicate `runWorkRemoval`). Per the owner directive it must NOT be a second implementation kept "in parity" — it becomes a **compact rendering of the SAME `WorkRow`** driven by the **shared `useWorkActions` hook** (extracted by Item 2). **This item:** adds the `variant: 'full' | 'compact'` prop to `WorkRow`, and in `HomeDashboard.renderYourWork` **DELETES** the bespoke row markup + the duplicate handlers and renders `<WorkRow variant="compact">` wired to `useWorkActions(homeTasksKey)`. The widget keeps its own (limited) home-tasks DATA fetch; it owns no row markup or mutation logic anymore. Every Item-1/2/3/4 fix then shows on both surfaces with nothing to propagate. (Implementer guards: `WorkRow` must not depend on `/work`-only context; the dashboard supplies toast + activity-modal context.)

**DEFER naming fold (adjudicator must-fix #10).** This item's `related-entity/referential` axis is a DEFER — **name the surface:** "a row whose linked entity was deleted/merged after the queue was fetched — what the legend cue / pressed-tint / busy-inert affordances read when the drill target no longer resolves." Collected under the per-plan "Known edge surfaces" note below.

**Keyboard copy fold (adjudicator must-fix #6).** This item's acceptance must NOT assert `e` works. Corrected: "c/x complete, o/Enter open, s snooze-menu, h snooze-tomorrow; **e is retired**." Item 4 VERIFIES Item 3's grammar copy; it does not edit the keyboard file.

**Acceptance.**
- The Complete circle reads unmistakably as a complete control at rest (legible glyph/tint, not hover-only) at every breakpoint, touch ≥44px, motion-safe.
- While a row is busy, its body `<Link>` is inert (pointer-events-none, aria-disabled, removed from tab order) so a click can't race the optimistic removal. **(sole owner of this edit)**
- The stale "absolutely-positioned action cluster" comments in `WorkRow` are deleted (no overlay/pointer-events bug exists in the shipped DOM). **(sole owner / first toucher)**
- The error state is no longer a dead-end "Please refresh" — it has a real Retry that SWR-revalidates (`mutate(workKey)`). Loading = skeleton (not spinner), empty = the actionable "all caught up" state — both preserved.
- Keyboard discoverability copy is truthful: "c/x complete, o/Enter open, s snooze-menu, h snooze-tomorrow; e is retired" — matches Item 3's grammar (VERIFY, not edit).
- The live-region announces the real result of the new completion flow (e.g., "Marked complete" + outcome-offered hint), never a false claim.
- **Dashboard "Your work" widget = single source of truth:** `renderYourWork` renders `<WorkRow variant="compact">` driven by `useWorkActions` — the bespoke row markup + duplicate handlers are DELETED. Every legibility/state/Undo/busy-inert behavior is inherited (not re-implemented); the widget owns only its limited data fetch. `WorkRow`'s `compact` variant is denser but the same component.
- A first-run legend cue (if added) stores its dismiss boolean in `sessionStorage` (UI flag, not PII) mirroring the existing `WorkSection` collapse pattern — SSR-deterministic, no React #418.
- `view_only` unaffected (drill-through only).

**Files.**
- `app/work/WorkRow.tsx` *(shared — Item 4 owns circle glyph + busy-inert Link + stale-comment deletion + the `variant: 'full' | 'compact'` prop)*.
- `app/work/WorkClient.tsx` *(shared)* — error-state Retry; VERIFY announce() copy.
- `app/components/home/HomeDashboard.tsx` *(Item 4 — single-source refactor)* — DELETE the bespoke `renderYourWork` row markup + duplicate `runWorkRemoval`/`handleWork*`; render `<WorkRow variant="compact">` wired to `useWorkActions(homeTasksKey)`.
- `app/work/useWorkActions.ts` *(reserved-shared — consumed; extracted by Item 2)* — the widget drives its rows through this shared hook.
- (No keyboard-file edit — VERIFY Item 3's.)

**Appetite.** Small/~1.5 days (grew with the dashboard-parity scope). Touches WorkRow LAST in build order.

**Permission scope.** No write affordance added; pure legibility/states. `view_only` unchanged.

**Edge cases (KEC).**
- *boundary/empty/over-limit* — **ACCEPT.** Empty queue → actionable "all caught up"; loading → skeleton; long subject → truncation keeps the circle + trailing block legible.
- *failure-mode* — **ACCEPT.** Queue load error → Retry (SWR revalidate), not a dead "refresh" string.
- *race/concurrency* — **ACCEPT.** Busy-inert Link closes the click-races-removal window (this item's edit).
- *permission/role* — **ACCEPT.** `view_only` renders drill-through only; no new affordance reaches it.
- *related-entity/referential* — **DEFER_TO_HUNT.** Named surface: a row whose linked entity was deleted/merged after the queue was fetched — what the legend cue / pressed-tint / busy-inert affordances read when the drill target no longer resolves. (→ Known edge surfaces note.)
- *input-validity* — **N/A.** No new input field (legibility/state work only).

**Parallel-safety.** Shares `WorkRow`/`WorkClient`/`HomeDashboard` under the ownership table (serial; Item 4 touches WorkRow last).

**Cohesion verdict.** EXTEND — legibility/state polish on existing components + widget parity. No net-new entity. **Ripple:** MUST-PROPAGATE — the dashboard widget reaches parity. VERIFY-UNAFFECTED: skeleton/empty states, keyboard grammar (Item 3 owns), hydration-safety.

---

## Known edge surfaces for /edge-cases (per-plan note)

Standalone `/edge-cases` auto-consumes this list:
- **(Item 4)** A row whose linked entity was deleted/merged *after* the queue was fetched — what the drill affordance, pressed-tint, busy-inert state, and the entity-host `?task=` open should read when the target no longer resolves (the destination page 404 is the floor; the row's own cue is the open question).
- **(Item 1, advisory)** The fetch-by-id fallback panel opening *over* a board that doesn't contain the row (panel-over-stale-board) — confirm closing returns to the board cleanly.
- **(Item 2, advisory)** Concurrent completion + outcome-log across two tabs/devices; the idempotency of the done-transition under repeated PATCH.

## Decisions (RESOLVED by owner — 2026-06-16)

1. **Completion toast = Undo + Add-outcome.** ✅ One-tap **Undo** on every completion (safety net) + an offered **Add outcome** for touch/follow-up rows. Small second-action slot on the toast component.
2. **"Schedule next follow-up" prominently defaulted.** ✅ Pre-suggested for call/email/meeting completions; opting out is the deliberate act (Pipedrive/HubSpot model). *(Owner may flag later if it should be quieter.)*
3. **Reassign → open the task detail panel.** ✅ Industry standard = the owner is a field on the task record; repoint the dead Reassign control to open the now-reliable task panel (real assignee control), not the no-op composer.
4. **Project follow-ups → open on the project page (full parity).** ✅ Industry standard = a follow-up opens in context on the record it's about; the project detail page gets its own in-context task-open, like company/person.
5. **Dashboard "Your work" widget = ONE SOURCE OF TRUTH with My Work (owner directive — architectural).** ✅ NOT a separate implementation kept "in parity." The widget becomes a differently-rendered (compact) view of the **same** row component + the **same** actions hook as the My Work panel. The hand-rolled `renderYourWork` row markup + its duplicate handlers are **DELETED**. See "Single-source-of-truth architecture" below + Item 4.

---

## Research appendix (competitive standard — citations)

Pipedrive: one-click checkmark with its own "marked as done" timestamp distinct from due date; conditional "schedule next" pop-up; contextual side-panel with linked-record tabs. HubSpot: checkmark complete decoupled from logging; "log first, complete second"; structured call/meeting outcome enum; default-on follow-up-task prompt; task↔record association so the click lands on the named entity. Salesforce: "Log a Call" *is* completion; row-level "Create Follow-Up Task" dropdown; documented inline-complete-on-virtualized-timeline flakiness = the canonical "some rows clickable, some not" failure → one stable always-rendered click target. Close: marking done auto-creates the activity on the timeline; Done/Snooze/Skip as three separate controls; click-into-the-record always. Things/Todoist/Superhuman: one-click reversible complete on a leading control, separate from the body; complete vs schedule are different gestures/edges; clicking the body opens the record. (Full URLs in the workflow transcript.)
