Task — engineering-spec@1

"Gantt view with dependency arrows — issue-to-issue precedence + critical path highlighting + roll-up to parent issue"

doneTASK-PROJ-016
module proj · class product · priority p1 · created 2026-05-16 · shipped null
depends on TASK-PROJ-002 · blocks none

§1 — Description (BCP-14 normative)

The Gantt view MUST extend the TASK-PROJ-015 Timeline with directed dependencies + critical-path highlighting. The contract:

  1. MUST define issue_dependencies table: (predecessor_id UUID, successor_id UUID, kind TEXT='finish_to_start', created_at, created_by, tenant_id, PK (predecessor_id, successor_id)).
  2. MUST reject INSERT that would create a cycle: BFS from successor_id through existing edges; if predecessor_id reachable → 422 cycle_detected.
  3. MUST render dependency edges as SVG arrows: right-angle path from predecessor bar's right edge → successor bar's left edge.
  4. MUST compute critical path per cycle: longest path through the DAG (where weight = issue.estimate or fallback to days span). Mark all bars on the critical path with a thick gold border.
  5. MUST memoise critical-path computation per (cycle_id, dependency-graph-version); recompute on dependency or estimate change.
  6. MUST expose CRUD endpoints:
  1. MUST emit memory audit rows:
  1. MUST support kbd shortcut: focused bar + D → opens dependency dialog (target picker = focus next bar, Enter to confirm).
  2. MUST propagate roll-up via parent_issue_id (per TASK-PROJ-001): parent's date range = min(child.starts_at) → max(child.ends_at); rendered as a parent bar above the children (collapsible group).
  3. MUST RLS-enforce.
  4. MUST pass axe-core (a11y for SVG arrows: role="presentation" since edges are decorative; navigation via dependency dialog only).
  5. MUST emit OTel:
  1. MUST compute and surface "slack" per non-critical-path issue: how many days the issue can slip before becoming critical. Rendered as a subtle ghost extension on the bar's right edge.
  2. MUST support ?show_critical_only=true URL filter — hide non-critical-path bars to focus on the bottleneck chain.
  3. MUST support cyberos gantt validate-graph --cycle <id> CLI that walks the dependency graph + reports anomalies (disconnected components, near-cycles, very-long paths > 30 days).
  4. MUST detect "near-cycles" (cycle would form if any one edge added) and emit proj.dependency_near_cycle SEV-3 audit — informational; helps operators avoid future cycle attempts.
  5. MUST support dependency types as forward-compatible (slice 4+ adds S→S/F→F/S→F): kind column accepts only finish_to_start in MVP; CHECK constraint allows future enum values without migration.
  6. MUST export Gantt-as-PDF (slice 3 minimal: A3 landscape, fixed zoom; full export options slice 4+) via GET /api/proj/cycles/:id/gantt.pdf.
  7. MUST include "earliest start" + "latest finish" annotations on critical-path bars (the dates that would absorb slippage).
  8. MUST support batch dependency CRUD: POST /api/proj/dependencies/batch for bulk import (max 100 edges per request); cycle detection runs on the batch as a whole.
  9. MUST include parent issue "completion %" in roll-up: parent bar visually segments by child completion (e.g. 3 of 5 children done = 60% green / 40% empty).
  10. MUST support kbd shortcut Shift+D to remove last-added dependency for focused bar (quick undo).

§2 — Why this design (rationale for humans)

Why finish-to-start only (DEC-371)? Other dependency kinds (start-to-start, finish-to-finish, start-to-finish) exist in Microsoft Project but rare in software work. Slice 3 ships F→S; slice 4+ adds others.

Why DAG (no cycles) (§1 #2)? Cycles are nonsensical (A waits on B waits on A); without prevention, critical-path algorithm infinite-loops. BFS cycle detect at write = O(N) per insert.

Why critical path matters (§1 #4)? Operators ask "what's the bottleneck path." Without highlight, the answer requires manual graph reading. Gold border = visual primitive.

Why memoise critical path (§1 #5)? Recompute is O(V+E) per render = perf cliff at 1000+ issues. Memoize on dependency-graph version (hash of edges).

Why parent roll-up (§1 #9)? Epics (parent issues) want to show "my whole subtree's date range" — operators planning at the epic level. Auto-computed eliminates manual epic-date maintenance.

Why D shortcut (§1 #8)? Kbd parity; mouse alternative requires modal dialog button.

Why slack (§1 #13)? Non-critical issues have flexibility ("this can slip 3 days without affecting the cycle"); operators need to see it.

Why critical-only filter (§1 #14)? Bottleneck focus: hide noise, see what blocks the cycle.

Why graph-validate CLI (§1 #15)? Operators auditing graph health want anomaly reports outside the UI; CLI is the operator entry point.

Why near-cycle detection (§1 #16)? Approaching cycles helps operators understand graph shape; informational warning.

Why forward-compatible kind (§1 #17)? Schema migration is expensive; future kinds slot in without DDL change.

Why PDF export (§1 #18)? Stakeholder reports need a portable artifact; PDF is the lingua franca.

Why earliest-start / latest-finish (§1 #19)? Operators rescheduling critical path need the bounds — "can I start this on Tuesday and still hit the cycle?"

Why batch dependency CRUD (§1 #20)? Bulk imports (migrating from other tools) need batch; per-edge HTTP is slow.

Why parent completion % (§1 #21)? Epic-level progress signal at a glance.

Why Shift+D quick undo (§1 #22)? Accidental dependency add is common; quick undo reduces friction.


§3 — API contract

Migration

-- services/proj-sync/migrations/0016_issue_dependencies.sql
CREATE TABLE issue_dependencies (
    predecessor_id  UUID NOT NULL,
    successor_id    UUID NOT NULL,
    kind            TEXT NOT NULL DEFAULT 'finish_to_start' CHECK (kind = 'finish_to_start'),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID NOT NULL,
    tenant_id       UUID NOT NULL,
    PRIMARY KEY (predecessor_id, successor_id),
    CHECK (predecessor_id != successor_id)   -- no self-edge
);
CREATE INDEX idx_deps_succ ON issue_dependencies (successor_id);
CREATE INDEX idx_deps_pred ON issue_dependencies (predecessor_id);

ALTER TABLE issue_dependencies ENABLE ROW LEVEL SECURITY;
CREATE POLICY deps_tenant_iso ON issue_dependencies
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

Rust

// services/proj-sync/src/dependencies/mod.rs
use serde::Serialize;

#[derive(Debug, thiserror::Error)]
pub enum DepError {
    #[error("cycle would form: {0} → ... → {1}")] CycleDetected(uuid::Uuid, uuid::Uuid),
    #[error("self-edge forbidden")] SelfEdge,
    #[error("dependency already exists")] AlreadyExists,
    #[error("db: {0}")] Db(String),
}

pub async fn add_dependency(
    pool: &sqlx::PgPool,
    predecessor: uuid::Uuid,
    successor: uuid::Uuid,
    subject: uuid::Uuid,
) -> Result<(), DepError> {
    if predecessor == successor { return Err(DepError::SelfEdge); }

    // Cycle detection: BFS from successor; if predecessor reached → cycle
    if reaches(pool, successor, predecessor).await? {
        return Err(DepError::CycleDetected(predecessor, successor));
    }

    sqlx::query(
        "INSERT INTO issue_dependencies (predecessor_id, successor_id, created_by, tenant_id)
         VALUES ($1, $2, $3, current_setting('app.tenant_id')::uuid)"
    ).bind(predecessor).bind(successor).bind(subject)
     .execute(pool).await
     .map_err(|e| if e.to_string().contains("issue_dependencies_pkey") {
         DepError::AlreadyExists
     } else { DepError::Db(e.to_string()) })?;

    emit_memory_row("proj.dependency_added", serde_json::json!({
        "predecessor_id": predecessor, "successor_id": successor,
        "by_subject_id": subject,
    })).await;
    metrics::counter!("proj_gantt_dependencies_total", "outcome" => "created").increment(1);
    Ok(())
}

async fn reaches(pool: &sqlx::PgPool, start: uuid::Uuid, target: uuid::Uuid) -> Result<bool, DepError> {
    // BFS through issue_dependencies starting at `start`; return true if `target` reached
    let mut frontier = vec![start];
    let mut visited = std::collections::HashSet::new();
    while let Some(node) = frontier.pop() {
        if !visited.insert(node) { continue; }
        if node == target { return Ok(true); }
        let next: Vec<uuid::Uuid> = sqlx::query_scalar(
            "SELECT successor_id FROM issue_dependencies WHERE predecessor_id = $1"
        ).bind(node).fetch_all(pool).await.map_err(|e| DepError::Db(e.to_string()))?;
        frontier.extend(next);
    }
    Ok(false)
}

Critical-path algorithm (TS)

// web/proj-client/src/views/Gantt/critical_path.ts
type Edge = { predecessor: string; successor: string };
type Issue = { id: string; estimate?: number; starts_at: Date; ends_at: Date };

export function computeCriticalPath(issues: Issue[], edges: Edge[]): string[] {
  const weight = new Map(issues.map(i => [i.id, i.estimate ?? daysBetween(i.starts_at, i.ends_at)]));
  const succ = new Map<string, string[]>();
  for (const e of edges) {
    if (!succ.has(e.predecessor)) succ.set(e.predecessor, []);
    succ.get(e.predecessor)!.push(e.successor);
  }
  // Topological sort
  const inDegree = new Map(issues.map(i => [i.id, 0]));
  for (const e of edges) inDegree.set(e.successor, (inDegree.get(e.successor) ?? 0) + 1);
  const queue: string[] = [];
  for (const [id, d] of inDegree) if (d === 0) queue.push(id);
  const sorted: string[] = [];
  while (queue.length) {
    const id = queue.shift()!;
    sorted.push(id);
    for (const s of succ.get(id) ?? []) {
      inDegree.set(s, inDegree.get(s)! - 1);
      if (inDegree.get(s) === 0) queue.push(s);
    }
  }
  // Longest path
  const dist = new Map<string, number>(issues.map(i => [i.id, weight.get(i.id) ?? 0]));
  const prev = new Map<string, string | null>();
  for (const id of sorted) {
    for (const s of succ.get(id) ?? []) {
      const newDist = dist.get(id)! + (weight.get(s) ?? 0);
      if (newDist > dist.get(s)!) {
        dist.set(s, newDist);
        prev.set(s, id);
      }
    }
  }
  // Trace path from the longest-dist node
  let endNode = [...dist.entries()].sort((a, b) => b[1] - a[1])[0][0];
  const path: string[] = [];
  let cur: string | null = endNode;
  while (cur) { path.unshift(cur); cur = prev.get(cur) ?? null; }
  return path;
}

§4 — Acceptance criteria

  1. Add dependency — POST → 201; row in issue_dependencies.
  2. Self-edge rejected — POST with predecessor === successor → 422 SelfEdge.
  3. Cycle rejected — A→B exists; POST B→A → 422 CycleDetected.
  4. Long cycle rejected — A→B→C→D; POST D→A → 422.
  5. Duplicate rejected — second POST same edge → 422 AlreadyExists.
  6. DELETE removes edge — 204; row gone.
  7. Edge rendered as arrow — fixture with edge → SVG path visible from predecessor bar to successor bar.
  8. Critical path highlighted — fixture: 3-issue chain → all 3 bars gold-bordered.
  9. Critical path branch chooses longest — fixture: A→B (3 days) and A→C (5 days) → A + C highlighted.
  10. Memoised — re-render without dependency change → no recompute.
  11. Recompute on estimate change — issue estimate change → new critical path; row critical_path_recomputed.
  12. Parent roll-up renders — parent issue with 3 children → parent bar = min/max of children.
  13. memory audit dependency_added — POST → row.
  14. memory audit dependency_removed — DELETE → row.
  15. Kbd D opens dialog — focused bar + D → dependency picker.
  16. RLS isolates — tenant A's edges invisible to tenant B.
  17. axe-core passes — SVG decorative; nav via dialog.
  18. Critical path metric — depth recorded per cycle.
  19. Slack rendered as ghost — non-critical bar with 3-day slack → ghost extension visible (AC for §1 #13).
  20. Show-critical-only filter?show_critical_only=true → non-critical bars hidden (AC for §1 #14).
  21. CLI validate-graph reports anomalies — disconnected components flagged (AC for §1 #15).
  22. Near-cycle detection — graph with 4 edges where 1 more would form cycle → SEV-3 audit (AC for §1 #16).
  23. PDF export — GET /gantt.pdf → A3-landscape PDF (AC for §1 #18).
  24. Earliest-start / latest-finish on critical bars — visible annotations (AC for §1 #19).
  25. Batch dependency CRUD — POST /batch with 50 edges → 200; cycle on batch detects (AC for §1 #20).
  26. Parent completion % — parent of 5 issues with 3 done → bar shows 60% (AC for §1 #21).
  27. Shift+D removes last dep — focused bar + Shift+D → last-added dependency removed (AC for §1 #22).

§5 — Verification

#[tokio::test]
async fn cycle_rejected() {
    let env = TestEnv::new().await;
    let (a, b) = (env.create_issue().await, env.create_issue().await);
    add_dependency(&env.pool, a, b, env.alice()).await.unwrap();
    let err = add_dependency(&env.pool, b, a, env.alice()).await.unwrap_err();
    assert!(matches!(err, DepError::CycleDetected(_, _)));
}

#[tokio::test]
async fn long_cycle_rejected() {
    let env = TestEnv::new().await;
    let (a, b, c, d) = (env.iss(), env.iss(), env.iss(), env.iss()).await;
    add_dependency(&env.pool, a, b, env.alice()).await.unwrap();
    add_dependency(&env.pool, b, c, env.alice()).await.unwrap();
    add_dependency(&env.pool, c, d, env.alice()).await.unwrap();
    let err = add_dependency(&env.pool, d, a, env.alice()).await.unwrap_err();
    assert!(matches!(err, DepError::CycleDetected(_, _)));
}

#[tokio::test]
async fn self_edge_rejected() {
    let env = TestEnv::new().await;
    let a = env.create_issue().await;
    let err = add_dependency(&env.pool, a, a, env.alice()).await.unwrap_err();
    assert!(matches!(err, DepError::SelfEdge));
}
test('critical path picks longest branch', () => {
  const issues = [
    { id: 'A', estimate: 1, starts_at: d(1), ends_at: d(2) },
    { id: 'B', estimate: 3, starts_at: d(2), ends_at: d(5) },
    { id: 'C', estimate: 5, starts_at: d(2), ends_at: d(7) },
  ];
  const edges = [
    { predecessor: 'A', successor: 'B' },
    { predecessor: 'A', successor: 'C' },
  ];
  const path = computeCriticalPath(issues, edges);
  expect(path).toEqual(['A', 'C']);
});

§6 — Implementation skeleton

(API + algorithms above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.critical_path_recomputed",
  "payload": {
    "cycle_id": "cyc-...",
    "path_issue_ids": ["iss-A", "iss-C", "iss-F"],
    "path_total_estimate_days": 9.0,
    "recomputed_at_ns": 1747407137483000000
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Cycle on insertBFS catches422Caller removes other edge first
Self-edgecheck422Caller fixes
DuplicatePK constraint422None
Issue deleted with active depscascade cleanupAll edges of deleted issue removedNone
Cross-tenant edgetenant_id mismatch in RLS0 rowsNone
100+ edges in graphBFS O(V+E)< 10ms typicalNone
Critical path with no edgestrivial: single longest-weight nodeSingle-bar highlightNone
Estimate missingfallback to date-spanPath still computableNone
Disconnected DAGmultiple roots; pick max overallSingle path returnedBy design
Parent date range empty (no children)omit roll-upNoneNone
Recompute thrashing on rapid editsmemoise + debounce 200msNoneNone
SVG arrow path glitch on resizeredraw on window resizeNoneNone
Touch devicedependency dialog buttonNoneNone
Kbd D collides with input fieldlistener scoped to barNoneNone
RLS bypassRLS policy0 rowsNone
Slack computation = 0 (issue is critical)rendered without ghostNoneNone
Critical-only filter empty resulthelpful copyNoneNone
Validate-graph CLI times outbounded by graph size; warnNoneOperator narrows
Near-cycle false positive (long valid path)configurable thresholdNoneNone
PDF export with > 200 issuespaginated; multi-page A3NoneNone
Earliest-start / latest-finish overlap (small slack)annotations stack verticallyNoneNone
Batch CRUD with 100 cyclesper-batch BFS checkreject + per-edge statusCaller fixes
Parent completion % when child unestimatedcounts done/total; %-of-countNoneNone
Shift+D with no dependency to removeno-opNoneNone
PDF export rendering failsfallback to HTML exportNoneNone
Batch dep import partial failureper-edge statuspartial commitCaller retries failed
Graph > 10K issuesBFS still bounded but slowsev-3 warningSlice 4+ optimise
Near-cycle detection sensitivity tuningper-tenant configNoneNone
PDF export queuedasync job; email when readyNoneOperator

§11 — Implementation notes


End of TASK-PROJ-016.

As built (2026-07-02)

Client code lives under apps/web/src (there is no web/proj-client/).