# Task Dependencies — Implementation Plan

**Spec:** docs/specs/2026-05-31-task-dependencies.md  ·  **Slug:** task-dependencies  ·  **Wave:** 5
**Depends on:** foundation-auth-rbac, projects-module, tasks-board-engine

## Goal
Add finish-to-start task blocking relationships (Task A blocks Task B) on top of the existing tasks board engine. A blocked task cannot move to any column until every blocking task reaches an `is_terminal = true` status, with an OWNER/ADMIN override. The Gantt (Timeline) view renders dependency arrows and a server-computed critical path; Kanban cards show a blocked indicator. The whole feature is gated to the Business+ tier.

## Architecture
A new join table `task_dependencies` stores directed edges between `tasks(id)` rows owned by the same tenant and the same `project_id`. It plugs into the existing `tasks` and `task_statuses` tables from `tasks-board-engine` (consuming `tasks.id`, `tasks.project_id`, `tasks.status_id`, and `task_statuses.is_terminal`) and `users(id)` from `foundation-auth-rbac` (for `created_by`).

Data flow:
- **Add edge** (`POST /api/tasks/:id/dependencies`): validates both tasks share `project_id`, runs a server-side DFS cycle check over the existing edges, inserts on success, else returns `409`.
- **Status-change enforcement**: the existing `PATCH /api/tasks/:id` handler (owned by `tasks-board-engine`) is extended to call a new `getUnsatisfiedBlockers(taskId)` guard before applying a `status_id` change. If unsatisfied blockers exist and the actor is not OWNER/ADMIN, it returns `409` with the blocker list; OWNER/ADMIN may pass `override: true`, which is echoed in the response metadata for activity feeds.
- **Critical path** (`GET /api/projects/:id/critical-path`): builds the project dependency graph, runs topological sort + longest-path, and caches the result per project revision in KV (`RATELIMIT_KV` binding is auth-only — use the general `KV` binding) keyed by `critpath:{tenantId}:{projectId}:{rev}`. The revision counter is bumped whenever tasks or dependencies in the project change.

Upstream tables consumed: `tasks`, `task_statuses`, `users`. Upstream exports consumed: `tenantQuery`, `authMiddleware`, `requirePermission`, `requireTier`, `requireModuleEnabled`, `serializeTask`, `TaskObject`, `buildPaginated`, `useUpgradeModal`, `useTierGate`, `TenantTier`. UI primitives consumed from `@zync/ui`: `Sheet`, `Dialog`, `Button`, `Badge`, `Radio`, `Command`, `EmptyState`, `Spinner`, `Tooltip`.

## Tech Stack
- **DB (`packages/db`):** Drizzle ORM + drizzle-kit, `@neondatabase/serverless` over Hyperdrive binding `DB`. New schema file `task-dependencies.ts`, new query module `task-dependencies.ts`, new validation module.
- **API (`apps/zync-api`):** Hono on Cloudflare Workers. New route group `routes/tasks/dependencies.ts` mounted under the existing `/api/tasks` router; new route `routes/projects/critical-path.ts`. Middleware chain: `authMiddleware` → `requireModuleEnabled('tasks')` → `requireTier('business')` → `requirePermission('projects:read'|'projects:write')`. Critical-path caching uses the `KV` binding.
- **App (`apps/zync-app`):** Vite + React, TanStack Query v5. New `DependenciesSection` for the task detail sheet, `BlockedMoveDialog`, Kanban blocked-card decoration, Gantt dependency-arrow + critical-path overlay built on the existing dhtmlx Gantt instance.
- **Types (`@zync/types`):** new `TaskDependency`, `DependencyDirection`, `CriticalPathResult` exported types.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| TD-1 Schema | 1 | `packages/db/src/schema/task-dependencies.ts`, schema index, migration | No (blocks all) |
| TD-2 Types + validation | 2, 3 | `packages/types/src/task-dependencies.ts`, `packages/db/src/validation/task-dependencies.ts` | After TD-1; parallel to each other |
| TD-3 Queries + graph algos | 4, 5, 6 | `packages/db/src/queries/task-dependencies.ts` | After TD-2 (4 blocks 5,6) |
| TD-4 API routes | 7, 8, 9 | `apps/zync-api/src/routes/tasks/dependencies.ts`, `routes/projects/critical-path.ts` | After TD-3; 7-8 parallel, 9 after 4 |
| TD-5 Enforcement wiring | 10 | `apps/zync-api/src/routes/tasks/index.ts` (existing PATCH) | After TD-3 |
| TD-6 Client data layer | 11 | `apps/zync-app/src/features/tasks/api/dependencies.ts` | After TD-4 |
| TD-7 Task-detail UI | 12 | `apps/zync-app/src/features/tasks/components/DependenciesSection.tsx` | After TD-6 |
| TD-8 Move-blocked dialog | 13 | `apps/zync-app/src/features/tasks/components/BlockedMoveDialog.tsx` | After TD-6 |
| TD-9 Kanban indicator | 14 | Kanban card component (existing) | After TD-6 |
| TD-10 Gantt overlay | 15 | Timeline/Gantt component (existing) | After TD-6 |

## Tasks

### Task 1: Database schema & migration for `task_dependencies`
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/task-dependencies.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export the new schema)
- Create: `packages/db/migrations/<timestamp>_task_dependencies.sql`
**Steps:**
- [ ] Define the Drizzle table `taskDependencies` mapping the DDL below (UUID PK with `gen_random_uuid()`, both FKs `ON DELETE CASCADE`, `created_by` FK to `users`).
- [ ] Add the unique constraint on `(blocking_task_id, blocked_task_id)` and the self-dependency CHECK.
- [ ] Add both single-column indexes for bidirectional lookups.
- [ ] Re-export from the schema barrel so `@zync/db` exposes `taskDependencies`.
- [ ] Generate the SQL migration with drizzle-kit and confirm it targets Neon Postgres (UUID, TIMESTAMPTZ, BOOLEAN — not SQLite).
**Schema / Interfaces:**
```sql
CREATE TABLE task_dependencies (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id        UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  blocking_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  blocked_task_id  UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  created_by       UUID NOT NULL REFERENCES users(id),
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (blocking_task_id, blocked_task_id),
  CHECK (blocking_task_id <> blocked_task_id)
);

CREATE INDEX idx_td_blocking ON task_dependencies(blocking_task_id);
CREATE INDEX idx_td_blocked  ON task_dependencies(blocked_task_id);
CREATE INDEX idx_td_tenant   ON task_dependencies(tenant_id);
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch; `task_dependencies` exists with the two CHECK/UNIQUE constraints and three indexes.
- [ ] Deleting a `tasks` row cascades and removes its dependency edges.

### Task 2: Shared types for dependencies & critical path
**Blocks:** 7, 8, 9, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/task-dependencies.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define `TaskDependency`, `DependencyDirection`, `DependenciesResponse`, and `CriticalPathResult` exactly as below.
- [ ] Re-export them from `@zync/types`.
**Schema / Interfaces:**
```ts
export type DependencyDirection = 'blocks' | 'blocked_by';

export interface TaskDependency {
  id: string;
  blocking_task_id: string;
  blocked_task_id: string;
  created_by: string;
  created_at: string; // ISO 8601
}

export interface DependenciesResponse {
  blocking: TaskObject[];   // tasks this task blocks
  blocked_by: TaskObject[]; // tasks that block this task
}

export interface CriticalPathResult {
  critical_task_ids: string[];
  critical_dependency_ids: string[];
}
```
**Acceptance:**
- [ ] `import { TaskDependency, CriticalPathResult } from '@zync/types'` type-checks across the API and app packages.

### Task 3: Zod validation schemas
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/validation/task-dependencies.ts`
**Steps:**
- [ ] Define `createDependencySchema`: an object with optional `blocking_task_id` and optional `blocked_task_id` (both UUID), refined so exactly one is supplied.
- [ ] Define `dependencyIdParamSchema` (UUID) for the DELETE route param.
- [ ] Export both for route-level `require-zod-validation-in-routes` compliance.
**Schema / Interfaces:**
```ts
import { z } from 'zod';

export const createDependencySchema = z
  .object({
    blocking_task_id: z.string().uuid().optional(),
    blocked_task_id: z.string().uuid().optional(),
  })
  .refine(
    (v) => Boolean(v.blocking_task_id) !== Boolean(v.blocked_task_id),
    { message: 'Supply exactly one of blocking_task_id or blocked_task_id' },
  );

export const dependencyIdParamSchema = z.object({ dependencyId: z.string().uuid() });
```
**Acceptance:**
- [ ] Supplying both ids or neither fails validation; supplying exactly one passes.

### Task 4: Core dependency queries (tenant-scoped CRUD + fetch)
**Blocks:** 5, 6, 7, 10  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/db/src/queries/task-dependencies.ts`
**Steps:**
- [ ] Implement every function wrapped in the `tenantQuery` factory so all statements are tenant-filtered (`tenant_id` always bound).
- [ ] `getDependencies(taskId)`: returns `{ blocking, blocked_by }` matching the spec response shape, joining `task_dependencies` to `tasks` and serializing each via `serializeTask`. `blocked_by` = the blocker tasks of edges where `blocked_task_id = taskId`. `blocking` = the blocked tasks of edges where `blocking_task_id = taskId` (the tasks this task blocks).
- [ ] `getProjectEdges(projectId)`: returns all `{ id, blocking_task_id, blocked_task_id }` edges for a project (join through `tasks` to filter by `project_id`) — used by cycle detection and critical path.
- [ ] `insertDependency({ blockingTaskId, blockedTaskId, createdBy })`: inserts one edge; relies on the UNIQUE constraint for idempotency (translate unique violation to a domain error).
- [ ] `deleteDependency(dependencyId)`: deletes one edge by id within the tenant.
- [ ] `assertSameProject(taskAId, taskBId)`: loads both tasks, throws `CrossProjectDependencyError` if `project_id` differs or either is missing.
- [ ] After any insert/delete, call `bumpProjectRevision(projectId)` (Task 6) so the critical-path cache invalidates.
**Schema / Interfaces:**
```ts
export interface InsertDependencyInput {
  blockingTaskId: string;
  blockedTaskId: string;
  createdBy: string;
}
export async function getDependencies(db: Db, tenantId: string, taskId: string): Promise<DependenciesResponse>;
export async function getProjectEdges(db: Db, tenantId: string, projectId: string): Promise<TaskDependency[]>;
export async function insertDependency(db: Db, tenantId: string, input: InsertDependencyInput): Promise<TaskDependency>;
export async function deleteDependency(db: Db, tenantId: string, dependencyId: string): Promise<void>;
export async function assertSameProject(db: Db, tenantId: string, taskAId: string, taskBId: string): Promise<{ projectId: string }>;
export class CrossProjectDependencyError extends Error {}
```
**Acceptance:**
- [ ] `getDependencies` returns serialized `TaskObject`s matching the spec response keys `blocking` / `blocked_by`.
- [ ] All statements omit no `tenant_id` filter (verified against `tenantQuery`).

### Task 5: Server-side cycle detection (DFS)
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Modify: `packages/db/src/queries/task-dependencies.ts`
**Steps:**
- [ ] Implement `wouldCreateCycle({ projectId, blockingTaskId, blockedTaskId })`: load `getProjectEdges`, build an adjacency map blocker→blocked, add the candidate edge, run an iterative DFS from `blockedTaskId` following blocker→blocked edges, and return `true` if `blockingTaskId` is reachable (i.e. the new edge closes a cycle).
- [ ] Use an explicit stack + visited set (O(V+E)); never recurse unbounded.
- [ ] Treat the graph as directed: an edge `blocking_task_id → blocked_task_id` means "blocker points to the task it blocks".
**Schema / Interfaces:**
```ts
export async function wouldCreateCycle(
  db: Db, tenantId: string,
  args: { projectId: string; blockingTaskId: string; blockedTaskId: string },
): Promise<boolean>;
```
**Acceptance:**
- [ ] A→B then B→A is detected as a cycle; A→B, B→C, C→A is detected; A→B, A→C is not.
- [ ] DFS terminates on disconnected and self-referential candidate inputs without stack overflow.

### Task 6: Critical-path computation + cache + revision counter
**Blocks:** 9  ·  **Blocked by:** 4
**Files:**
- Modify: `packages/db/src/queries/task-dependencies.ts`
**Steps:**
- [ ] Implement `computeCriticalPath(projectId)`: load `getProjectEdges` + the project's tasks (id, `estimated_hours`, `status_id`). Build the DAG, topologically sort it, and compute the longest path by accumulated weight (weight = `estimated_hours` per task, default 1 when null). Return `{ critical_task_ids, critical_dependency_ids }` where `critical_dependency_ids` are the edge ids along the longest chain.
- [ ] Implement `getProjectRevision(projectId)` / `bumpProjectRevision(projectId)` backed by KV key `critpath:rev:{tenantId}:{projectId}` (monotonic integer; create at 0).
- [ ] Implement `getCachedCriticalPath(env, projectId)`: read KV key `critpath:{tenantId}:{projectId}:{rev}`; on miss compute, write back (JSON), return. Invalidation is implicit: a bumped revision changes the cache key.
- [ ] If topological sort detects a cycle (should be impossible given Task 5 guards inserts), throw — do not loop forever.
**Schema / Interfaces:**
```ts
export async function computeCriticalPath(db: Db, tenantId: string, projectId: string): Promise<CriticalPathResult>;
export async function getCachedCriticalPath(
  env: Env, db: Db, tenantId: string, projectId: string,
): Promise<CriticalPathResult>;
export async function getProjectRevision(env: Env, tenantId: string, projectId: string): Promise<number>;
export async function bumpProjectRevision(env: Env, tenantId: string, projectId: string): Promise<void>;
```
**Acceptance:**
- [ ] Critical path of a known fixture (A→B→D longest vs A→C) returns the longest weighted chain's task ids and the connecting edge ids.
- [ ] Adding/removing a dependency bumps the revision so the next `getCachedCriticalPath` recomputes.

### Task 7: API — dependency CRUD routes
**Blocks:** 11  ·  **Blocked by:** 4, 5, 3, 2
**Files:**
- Create: `apps/zync-api/src/routes/tasks/dependencies.ts`
- Modify: `apps/zync-api/src/routes/tasks/index.ts` (mount the sub-router)
**Steps:**
- [ ] Apply the middleware chain to all routes: `authMiddleware` → `requireModuleEnabled('tasks')` → `requireTier('business')`. Then `requirePermission('projects:read')` for GET, `requirePermission('projects:write')` for POST/DELETE.
- [ ] `GET /api/tasks/:id/dependencies` → call `getDependencies`, return `{ blocking, blocked_by }`.
- [ ] `POST /api/tasks/:id/dependencies` → validate body with `createDependencySchema`. Resolve the pair: if `blocking_task_id` supplied, the edge is `(blocking_task_id → :id)`; if `blocked_task_id` supplied, the edge is `(:id → blocked_task_id)`. Call `assertSameProject` (→ 422 on cross-project), `wouldCreateCycle` (→ **409 Conflict** when true), then `insertDependency`. Return `{ id, blocking_task_id, blocked_task_id }` with 201.
- [ ] Translate the UNIQUE-violation domain error to 409 (duplicate edge).
- [ ] `DELETE /api/tasks/:id/dependencies/:dependencyId` → validate param, `deleteDependency`, return 204.
- [ ] All DB access goes through the query module (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```
GET    /api/tasks/:id/dependencies                  → DependenciesResponse              (projects:read, Business+)
POST   /api/tasks/:id/dependencies                  → { id, blocking_task_id, blocked_task_id }  (projects:write, Business+; 409 on cycle/dup, 422 cross-project)
DELETE /api/tasks/:id/dependencies/:dependencyId    → 204                                (projects:write, Business+)
```
**Acceptance:**
- [ ] Adding a cycle-closing edge returns 409; a duplicate edge returns 409; a cross-project edge returns 422.
- [ ] Non-Business tenants receive the tier-gate response from `requireTier('business')`.

### Task 8: API — critical-path route
**Blocks:** 15  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-api/src/routes/projects/critical-path.ts`
- Modify: `apps/zync-api/src/routes/projects/index.ts` (mount)
**Steps:**
- [ ] `GET /api/projects/:id/critical-path` → middleware `authMiddleware` → `requireModuleEnabled('tasks')` → `requireTier('business')` → `requirePermission('projects:read')`.
- [ ] Call `getCachedCriticalPath(env, db, tenantId, projectId)` and return `CriticalPathResult`.
**Schema / Interfaces:**
```
GET /api/projects/:id/critical-path → { critical_task_ids: string[], critical_dependency_ids: string[] }  (projects:read, Business+)
```
**Acceptance:**
- [ ] Returns the cached result on the second identical call without recomputation (verified by a revision-unchanged assertion).

### Task 9: API — blocker guard helper (consumed by PATCH)
**Blocks:** 10  ·  **Blocked by:** 4
**Files:**
- Modify: `packages/db/src/queries/task-dependencies.ts`
**Steps:**
- [ ] Implement `getUnsatisfiedBlockers(taskId)`: return the tasks that block `taskId` (edges where `blocked_task_id = taskId`) whose `status_id` maps to a `task_statuses` row with `is_terminal = false`. Join `task_dependencies` → `tasks` (blocker) → `task_statuses`, filter `is_terminal = false`, serialize via `serializeTask`.
- [ ] Return `[]` when the task has no blockers or all blockers are terminal.
**Schema / Interfaces:**
```ts
export async function getUnsatisfiedBlockers(db: Db, tenantId: string, taskId: string): Promise<TaskObject[]>;
```
**Acceptance:**
- [ ] A task blocked only by terminal-status tasks returns `[]`; a task with any non-terminal blocker returns those blockers serialized.

### Task 10: Enforce blocking on task status change (extend PATCH)
**Blocks:** 13  ·  **Blocked by:** 4, 9
**Files:**
- Modify: `apps/zync-api/src/routes/tasks/index.ts` (existing `PATCH /api/tasks/:id` handler)
**Steps:**
- [ ] When the PATCH body changes `status_id`, call `getUnsatisfiedBlockers(:id)` before applying the update.
- [ ] If blockers exist and the actor's role is **not** OWNER or ADMIN, return **409 Conflict** with `{ error: 'task_blocked', blockers: TaskObject[] }` and do not mutate.
- [ ] If blockers exist and the actor **is** OWNER/ADMIN, allow the move only when the body includes `override: true`; record `overridden: true` and `overridden_blockers: string[]` in the PATCH response metadata so clients can surface it in activity feeds. Without `override: true`, still return the 409 blocker payload (so the dialog can offer "Move anyway").
- [ ] Leave all existing PATCH behavior (position, assignee, etc.) untouched; the guard runs only for `status_id` transitions.
- [ ] Use the OWNER/ADMIN check via the session role from `authMiddleware`; do not invent a new permission.
**Schema / Interfaces:**
```ts
// PATCH /api/tasks/:id  (extended response metadata on override)
interface TaskPatchMeta { overridden?: boolean; overridden_blockers?: string[] }
// 409 body when blocked & no override:
interface TaskBlockedError { error: 'task_blocked'; blockers: TaskObject[] }
```
**Acceptance:**
- [ ] A member moving a blocked task gets 409 with the blocker list and the task is unchanged.
- [ ] An OWNER/ADMIN with `override: true` succeeds; the response includes `overridden: true` and the blocker ids.

### Task 11: Client data layer (TanStack Query hooks)
**Blocks:** 12, 13, 14, 15  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/features/tasks/api/dependencies.ts`
**Steps:**
- [ ] Typed fetch wrappers + TanStack Query v5 hooks against the four endpoints, importing `@zync/types`.
- [ ] `useTaskDependencies(taskId)` → GET; `useAddDependency(taskId)` (mutation, invalidates the task's dependencies + the project critical-path query); `useRemoveDependency(taskId)` (mutation, same invalidations); `useCriticalPath(projectId, { enabled })`.
- [ ] On a 409 from the add mutation, surface a typed `CycleError` so the picker can show "would create a cycle".
- [ ] On a 409 `task_blocked` from the task PATCH mutation (board move), expose the `blockers` payload so the move dialog (Task 13) can render it.
**Schema / Interfaces:**
```ts
export function useTaskDependencies(taskId: string): UseQueryResult<DependenciesResponse>;
export function useAddDependency(taskId: string): UseMutationResult<TaskDependency, ApiError, { blocking_task_id?: string; blocked_task_id?: string }>;
export function useRemoveDependency(taskId: string): UseMutationResult<void, ApiError, { dependencyId: string }>;
export function useCriticalPath(projectId: string, opts?: { enabled?: boolean }): UseQueryResult<CriticalPathResult>;
```
**Acceptance:**
- [ ] Adding/removing a dependency refetches both the dependencies list and the critical-path query for the project.

### Task 12: Task-detail Dependencies section
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-app/src/features/tasks/components/DependenciesSection.tsx`
- Modify: task detail sheet container (existing, from `tasks-detail-communication`/board engine) to render the section
**Steps:**
- [ ] Render a **Dependencies** section with two groups: **Blocked by** and **Blocks**, using `useTaskDependencies`.
- [ ] Each row shows the linked task title, its status name, and a state pill: terminal-status blocker → "Unblocked" (✓), non-terminal blocker → "Waiting" (⏳); under **Blocks**, show "(waiting on this task)".
- [ ] **[+ Add]** opens a `Command` picker that searches tasks within the **same project** only; a `Radio` choice selects direction ("This task is blocked by [selected]" → sends `blocking_task_id`; "This task blocks [selected]" → sends `blocked_task_id`).
- [ ] On add, call `useAddDependency`; on `CycleError` (409) show an inline error ("Can't add: would create a circular dependency").
- [ ] Each existing row has a remove control wired to `useRemoveDependency`.
- [ ] Gate the entire section behind Business+ using `useTierGate`; for non-Business tenants render an upsell affordance via `useUpgradeModal` instead of the live section.
- [ ] A11y: section is a labelled region (`aria-labelledby`), the add picker is keyboard-navigable (`Command`), state pills carry text (not color-only), and the picker respects RTL/Hebrew via the existing direction context.
**Acceptance:**
- [ ] Adding a dependency in either direction updates both groups without a full page reload.
- [ ] The picker never lists tasks from other projects.
- [ ] Non-Business tenants see the upgrade prompt, not the functional section.

### Task 13: Blocked-move dialog (Kanban/Gantt move enforcement)
**Blocks:** —  ·  **Blocked by:** 11, 10
**Files:**
- Create: `apps/zync-app/src/features/tasks/components/BlockedMoveDialog.tsx`
- Modify: Kanban + Timeline move handlers (existing) to catch the 409 `task_blocked` response
**Steps:**
- [ ] When a board/Gantt move PATCH returns 409 `task_blocked`, open a `Dialog` titled "Task is blocked" listing each blocker (title, status name, assignee) from the payload.
- [ ] Buttons: **Cancel** (always) and **Move anyway** (rendered only when the current user role is OWNER or ADMIN). **Move anyway** re-issues the PATCH with `override: true`.
- [ ] On successful override, optimistically apply the move and toast that an override was recorded.
- [ ] A11y: `Dialog` traps focus, has `role="alertdialog"` semantics, the warning is conveyed in text, and actions are keyboard-reachable; honor `prefers-reduced-motion` for the open/close transition.
**Acceptance:**
- [ ] A non-OWNER/ADMIN sees only Cancel; the card snaps back to its original column.
- [ ] OWNER/ADMIN can confirm "Move anyway" and the move persists with override metadata.

### Task 14: Kanban blocked indicator
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Modify: Kanban task-card component (existing, from `tasks-board-engine`)
**Steps:**
- [ ] When a card's task has any non-terminal blocker (derived from `useTaskDependencies` or a board-level dependencies map), show a chain-link `Badge`/icon and a "Blocked by: {firstBlockerTitle}" line.
- [ ] Apply a muted dashed card border for blocked cards using design-system tokens (no hardcoded colors — `no-hardcoded-colors`).
- [ ] A11y: the chain icon has an `aria-label` ("Blocked"), and the blocked state is announced in text, not by border style alone.
**Acceptance:**
- [ ] A blocked card shows the chain badge + dashed border; an unblocked card shows neither.

### Task 15: Gantt dependency arrows + critical-path overlay
**Blocks:** —  ·  **Blocked by:** 11, 8
**Files:**
- Modify: Timeline/Gantt view component (existing dhtmlx Gantt integration from `tasks-board-engine`)
**Steps:**
- [ ] Feed dependency edges (from the project's tasks + their dependencies) into dhtmlx Gantt as finish-to-start links so connecting arrows render between dependent bars.
- [ ] Add a **[Critical path]** toggle button to the Gantt toolbar. When on, call `useCriticalPath(projectId)` and style bars whose id is in `critical_task_ids` and links whose id is in `critical_dependency_ids` red; non-critical dependencies stay gray. Use design-system tokens for both states.
- [ ] Toggle off restores default (gray) styling.
- [ ] Gate the toggle behind Business+ via `useTierGate`.
- [ ] A11y: the toggle is a real button with `aria-pressed`; critical/non-critical distinction is not conveyed by color alone (add a text/marker cue, e.g. an asterisk in the bar's `aria-label`); honor `prefers-reduced-motion` for any arrow-draw animation.
**Acceptance:**
- [ ] Dependency arrows render between linked bars; toggling Critical path highlights exactly the server-returned task and edge ids in red.
- [ ] Toggling off removes the red highlighting and the critical-path query is not refetched while off.
