From abb2ba34e5bf944cd715fbac3d1443a57cc55462 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:36:45 -0400
Subject: [PATCH 01/18] docs: add Phase 3C.2 attack chain graph view design
spec
Per-engagement interactive graph visualization using force-graph (vasturiano),
edge curation UI, MITRE ATT&CK phase coloring, server-side filtering for scale.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...phase3c2-attack-chain-graph-view-design.md | 358 ++++++++++++++++++
1 file changed, 358 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-13-phase3c2-attack-chain-graph-view-design.md
diff --git a/docs/superpowers/specs/2026-04-13-phase3c2-attack-chain-graph-view-design.md b/docs/superpowers/specs/2026-04-13-phase3c2-attack-chain-graph-view-design.md
new file mode 100644
index 0000000..465b1ad
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-13-phase3c2-attack-chain-graph-view-design.md
@@ -0,0 +1,358 @@
+# Phase 3C.2: Per-Engagement Attack Chain Graph View — Design Specification
+
+**Date:** 2026-04-13
+**Status:** Draft
+**Author:** slabl + Claude
+**Depends on:** Phase 3C.1 (merged)
+
+## 1. Overview
+
+Phase 3C.2 adds an interactive, per-engagement attack chain graph visualization to the web dashboard. Users explore how findings relate to each other within an engagement — clicking nodes to inspect findings, hovering edges to see linking reasons, and confirming or rejecting candidate links to curate the graph.
+
+The visualization uses `force-graph` (vasturiano) rendered on a standalone page. Scale is handled server-side: the backend filters and caps the subgraph so the renderer always gets a manageable dataset regardless of engagement size.
+
+No global cross-engagement view (3C.3), no Cypher DSL (3C.4), no Bayesian calibration (3C.3).
+
+## 2. Decisions
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Graph library | `force-graph` (vasturiano) | Purpose-built force-directed graph renderer, ~50KB, rich interaction API, existing `to_force_graph()` adapter from 3C.1 |
+| Single library | Yes — no cosmograph fallback | Scale is solved server-side via filtering and max_nodes cap; the renderer never sees more than ~1,000 nodes. A second library adds maintenance burden for a problem the backend already solves |
+| Page placement | Standalone page at `/engagements/:id/chain` | Force-graph needs maximum viewport; a tab would constrain height. Graph curation is a focused workflow, not a quick-glance tab |
+| Node coloring | Severity-based (critical=red, high=orange, medium=yellow, low=blue, info=gray) | Consistent with existing `SeverityBadge.vue` palette. MITRE phase shown as small abbreviation pill, not primary color |
+| MITRE ATT&CK display | Small tactic abbreviation pill on nodes (e.g. "IA", "EX", "PE"), not swim lanes | Swim lanes constrain layout and add complexity; deferred to 3C.3 as optional layout mode |
+| Edge curation scope | Global (user-scoped) | `FindingRelation` unique constraint is `(source, target, user_id)`. One edge, one status. Consistent across views. Required for 3C.3 Bayesian calibration |
+| Detail panel | Right-side drawer (30% width) | Curation requires stable surface for reasons/rationale + confirm/reject buttons. Popovers are too transient |
+| Post-curation update | Optimistic local update | Re-fetching would reset the force simulation and destroy the user's spatial mental model |
+| Filter changes | Re-fetch with position preservation | Pin `fx`/`fy` on nodes present in both old and new datasets. New nodes animate in from neighbors. Removed nodes fade out |
+| Neighborhood expansion | Explicit "Expand Neighbors" button in detail panel | Avoids ambiguous double-click. Button shows neighbor count. Fetches via `seed_finding_id` + `hops` params |
+
+## 3. Architecture
+
+### 3.1 Data flow
+
+```
+EngagementDetailView ChainGraphView (standalone page)
+ [View Attack Chain] ──────────────► GET /api/chain/subgraph?engagement_id=X
+ &severity=critical,high
+ &status=auto_confirmed,user_confirmed,candidate
+ &max_nodes=500
+ │
+ ▼
+ to_force_graph() adapter
+ │
+ ▼
+ force-graph renderer
+ │
+ ┌─────────┴──────────┐
+ ▼ ▼
+ node click edge click
+ │ │
+ ▼ ▼
+ Detail panel: Detail panel:
+ finding info, reasons, rationale,
+ entities, drift badge,
+ [Expand Neighbors] [Confirm] [Reject]
+ │ │
+ ▼ ▼
+ GET subgraph PATCH /api/chain/relations/:id
+ (seed + hops, {status: "user_confirmed"}
+ merge into graph) (optimistic local update)
+```
+
+### 3.2 No new database tables
+
+Everything builds on existing 3C.1 tables: `ChainFindingRelation`, `ChainEntity`, `ChainEntityMention`. The `RelationStatus` enum already includes `USER_CONFIRMED` and `USER_REJECTED`. The only addition is a computed `drift` boolean derived from comparing current `reasons_json` against `confirmed_at_reasons_json`.
+
+## 4. Backend API
+
+### 4.1 `GET /api/chain/subgraph`
+
+Returns a filtered, capped subgraph for an engagement.
+
+**Query parameters:**
+
+| Param | Type | Default | Description |
+|-------|------|---------|-------------|
+| `engagement_id` | string | required | Engagement to query |
+| `severity` | comma-separated | all | Filter nodes by severity |
+| `status` | comma-separated | `auto_confirmed,user_confirmed,candidate` | Filter edges by relation status |
+| `max_nodes` | int | 500 | Hard cap on returned nodes |
+| `seed_finding_id` | string | null | Start from this node's neighborhood (for expansion). When set, `max_nodes` still applies to the neighborhood result. |
+| `hops` | int | 2 | Neighborhood radius when `seed_finding_id` is provided |
+| `format` | string | `force-graph` | `force-graph` or `canonical` |
+
+**Response (force-graph format):**
+
+```json
+{
+ "graph": {
+ "nodes": [
+ {
+ "id": "f-abc",
+ "name": "SQL Injection in /login",
+ "severity": "critical",
+ "tool": "sqlmap",
+ "phase": "initial-access"
+ }
+ ],
+ "links": [
+ {
+ "id": "rel-123",
+ "source": "f-abc",
+ "target": "f-def",
+ "value": 0.82,
+ "status": "auto_confirmed",
+ "drift": false,
+ "reasons": ["shared_strong_entity", "temporal"],
+ "relation_type": "enables",
+ "rationale": "Both findings target the same host (10.0.0.5) with temporal proximity..."
+ }
+ ]
+ },
+ "meta": {
+ "total_findings": 1832,
+ "rendered_findings": 247,
+ "filtered": true,
+ "generation": 3
+ }
+}
+```
+
+**Implementation:** new method on `ChainService` that delegates to `PostgresChainStore` for the filtered query, runs `to_force_graph()` adapter, computes drift on each relation, and attaches metadata.
+
+**Link objects include `id`** so the frontend can issue `PATCH` requests for curation without reverse-looking up by source+target.
+
+**Drift computation:** for each link with status `user_confirmed`, compare `reasons_json` to `confirmed_at_reasons_json`. If they differ, `drift: true`. Computed server-side in the DTO layer, not pushed to the frontend to diff.
+
+### 4.2 `PATCH /api/chain/relations/{relation_id}`
+
+Updates edge status for curation.
+
+**Request:**
+
+```json
+{
+ "status": "user_confirmed"
+}
+```
+
+**Validation:** only `user_confirmed` and `user_rejected` are accepted. Attempting to set `auto_confirmed`, `candidate`, or `rejected` returns 422.
+
+**Response:** the updated relation object with new status.
+
+**Side effect:** when setting `user_confirmed`, snapshot current `reasons_json` into `confirmed_at_reasons_json` (for future drift detection).
+
+**Auth:** scoped to current user's relations. Returns 404 if the relation belongs to another user.
+
+### 4.3 Existing endpoints used as-is
+
+- `POST /api/chain/rebuild` — trigger chain analysis for the engagement
+- `GET /api/chain/runs/{run_id}` — poll rebuild progress
+
+## 5. Frontend
+
+### 5.1 Page layout
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ ← Back Engagement Name [Run Analysis] [Severity] [Status] │
+├──────────────────────────────────────────────────┬──────────────────┤
+│ │ │
+│ │ Detail Panel │
+│ │ │
+│ force-graph canvas │ (node or edge │
+│ (fills remaining height) │ details) │
+│ │ │
+│ │ [Confirm] │
+│ │ [Reject] │
+│ │ │
+│ │ [Expand │
+│ │ 12 Neighbors] │
+│ │ │
+├──────────────────────────────────────────────────┴──────────────────┤
+│ ● Critical ● High ● Medium ● Low ● Info │
+│ ── Confirmed ╌╌ Candidate ── Rejected Showing 247 of 1,832 │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+- **Graph area:** ~70% width when detail panel is open, 100% when closed. Full viewport height minus toolbar and legend bar.
+- **Detail panel:** right-side drawer, ~30% width. Opens on node/edge click, closes on X or clicking empty canvas.
+- **Legend bar:** bottom strip, always visible. Severity color key, edge style key, filtered/total node count.
+
+### 5.2 Node rendering
+
+Custom `nodeCanvasObject` callback:
+
+- **Shape:** filled circle, radius scaled by connection count (capped to avoid giant nodes)
+- **Color:** severity-mapped — critical: `#e74c3c`, high: `#e67e22`, medium: `#f1c40f`, low: `#3498db`, info: `#95a5a6`. Matches `SeverityBadge.vue` palette.
+- **Label:** finding title, truncated to ~30 chars, rendered below node. Hidden at low zoom, always shown on hover.
+- **MITRE pill:** small tactic abbreviation (e.g. "IA", "EX", "PE") top-right of node. Visible at medium+ zoom.
+- **Selection indicator:** bright highlight ring (thicker stroke, contrasting color) when selected.
+
+### 5.3 Edge rendering
+
+Custom `linkCanvasObject` callback:
+
+| Status | Style |
+|--------|-------|
+| `auto_confirmed` | Solid line, opacity proportional to weight |
+| `user_confirmed` | Solid line, full opacity, slightly thicker |
+| `candidate` | Dashed line, lower opacity |
+| `rejected` / `user_rejected` | Thin red dashed line (hidden by default filter) |
+
+- **Direction:** small arrowhead at target end
+- **Drift badge:** small warning triangle (▲) at edge midpoint when `drift: true`
+- **Selection indicator:** thicker line with glow effect when selected
+
+### 5.4 Interactions
+
+| Action | Result |
+|--------|--------|
+| Click node | Open detail panel with finding info (title, severity, tool, phase, linked entities, neighbor count). Show "Expand N Neighbors" button. |
+| Click edge | Open detail panel with weight, status, all firing rule reasons with individual weight contributions, LLM rationale (if present), drift warning (if applicable). Show Confirm/Reject buttons. |
+| Hover node | Tooltip with title + severity. Highlight connected edges. |
+| Hover edge | Tooltip with weight + status + primary reason. |
+| Click empty canvas | Close detail panel. Deselect current node/edge. |
+| Scroll | Zoom in/out. Node labels appear/hide based on zoom level threshold. |
+| Drag node | Reposition (force-graph handles natively). |
+
+### 5.5 Neighborhood expansion
+
+1. User clicks node → detail panel shows "Expand N Neighbors" button with count
+2. User clicks button
+3. Frontend calls `GET /api/chain/subgraph?seed_finding_id=X&hops=2` with current severity/status filters
+4. Merge response into existing graph data (deduplicate nodes/links by ID)
+5. New nodes animate in from the seed node's position (force simulation handles naturally)
+6. Legend bar node count updates
+
+### 5.6 Edge curation flow
+
+1. User clicks an edge → detail panel opens
+2. Panel shows: source finding title → target finding title, weight, status badge, each rule reason with its weight contribution, LLM rationale (if any), drift warning (if any)
+3. User clicks Confirm or Reject
+4. `PATCH /api/chain/relations/:id` fires
+5. Optimistic update: edge style changes immediately (e.g. dashed candidate → solid confirmed)
+6. On error: revert edge style, show PrimeVue toast with error message
+
+### 5.7 Filter toolbar
+
+Integrated into the top toolbar row:
+
+- **Severity toggles:** PrimeVue `SelectButton` (multi-select) for critical/high/medium/low/info. Default: all on.
+- **Status toggles:** PrimeVue `SelectButton` (multi-select) for confirmed/candidate/rejected. Default: confirmed + candidate on.
+- **Reset button:** restores default filter state.
+
+Changing any filter re-fetches `GET /api/chain/subgraph` with updated params. On re-fetch, nodes present in both old and new datasets preserve their `fx`/`fy` positions. New nodes animate in. Removed nodes are dropped.
+
+### 5.8 Empty state
+
+When engagement has zero chain relations:
+
+- Centered empty-state component: icon + "No attack chain data yet"
+- "Run Chain Analysis" primary button
+- Click triggers `POST /api/chain/rebuild` with `engagement_id`
+- Progress bar appears, polls `GET /api/chain/runs/:id` every 2 seconds
+- On completion (`status: "done"`), auto-fetches subgraph and renders graph
+- On failure, shows error toast with the run's error message
+
+### 5.9 Navigation
+
+- **Entry point:** "View Attack Chain" button added to `EngagementDetailView.vue` header (next to the Delete button)
+- **Route:** `/engagements/:id/chain` added to router
+- **Back navigation:** "← Back" button in toolbar navigates to `/engagements/:id`
+
+### 5.10 Vue components
+
+| Component | Purpose | Est. lines |
+|-----------|---------|------------|
+| `ChainGraphView.vue` | Page component — data fetching, filter state, layout orchestration | ~200 |
+| `ForceGraphCanvas.vue` | Wrapper around `force-graph` instance — rendering config, custom draw callbacks, interaction events | ~300 |
+| `ChainDetailPanel.vue` | Right drawer — node details, edge details with reasons, curation buttons | ~250 |
+| `ChainFilterToolbar.vue` | Severity/status toggle buttons | ~80 |
+| `ChainLegend.vue` | Bottom bar — severity color key, edge style key, node count | ~60 |
+| `ChainEmptyState.vue` | Empty state + rebuild progress | ~80 |
+
+## 6. Testing
+
+### 6.1 Backend tests
+
+- **Subgraph endpoint:** filter combinations (severity subset, status subset, max_nodes cap), seed + hops neighborhood query, empty engagement (no findings), engagement with findings but no chain data (no relations), format parameter (force-graph vs canonical)
+- **Relation PATCH:** valid transitions (candidate → user_confirmed, candidate → user_rejected, user_confirmed → user_rejected, user_rejected → user_confirmed), invalid status values return 422, auth scoping (404 for another user's relation), `confirmed_at_reasons_json` snapshot on confirm
+- **Drift computation:** relation with unchanged reasons → `drift: false`, relation with changed reasons since confirm → `drift: true`, relation never confirmed → `drift: false`
+- **Rebuild → subgraph integration:** trigger rebuild, poll to completion, fetch subgraph, verify non-empty nodes and edges
+
+### 6.2 Frontend tests
+
+- **`ChainDetailPanel`:** renders node details (title, severity, tool, phase, entity list, neighbor count), renders edge details (weight, status, reasons with contributions, rationale, drift badge), confirm/reject buttons emit correct events with relation ID
+- **`ChainFilterToolbar`:** toggle state management, emits filter change event with correct severity/status arrays
+- **`ChainEmptyState`:** shows rebuild button, shows progress bar during polling
+- **`ForceGraphCanvas`:** no unit tests (canvas rendering not testable in jsdom). Manual browser verification.
+
+## 7. Scope boundaries
+
+### 7.1 In scope (3C.2)
+
+- `force-graph` per-engagement standalone page at `/engagements/:id/chain`
+- "View Attack Chain" button on `EngagementDetailView`
+- `GET /api/chain/subgraph` with filtering, max_nodes cap, seed neighborhood expansion
+- `PATCH /api/chain/relations/:id` for edge curation (confirm/reject)
+- Severity color coding on nodes
+- MITRE tactic abbreviation pill on nodes
+- Edge style encoding (solid/dashed/red by status, opacity by weight)
+- Right-side detail panel with node info, edge reasons/rationale, confirm/reject
+- Drift badge on edges where reasons changed post-confirmation
+- Filter toolbar (severity and status toggles)
+- Legend bar with color key, edge style key, and node count
+- Empty state with "Run Analysis" → rebuild polling → auto-load
+- Optimistic curation updates
+- Position-preserving re-renders on filter change
+- Neighborhood expansion via explicit button in detail panel
+- Selected node/edge highlight indicator
+
+### 7.2 Out of scope (deferred)
+
+| Feature | Deferred to |
+|---------|-------------|
+| Global cross-engagement graph view | 3C.3 |
+| Swim lane layout by MITRE phase | 3C.3 |
+| Attack vector scoring | 3C.3 |
+| Timeline playback | 3C.3 |
+| Path-as-report export | 3C.3 |
+| Bayesian weight calibration | 3C.3 |
+| Cypher query DSL / query editor | 3C.4 |
+| Server-side node clustering/aggregation | 3C.3 |
+| Keyboard shortcuts for graph navigation | Future |
+| Canvas accessibility (screen reader support) | Future |
+
+## 8. Estimated size
+
+| Layer | New/Modified | Est. lines |
+|-------|-------------|------------|
+| Backend: subgraph endpoint + service method | New | ~150 |
+| Backend: relation PATCH endpoint | New | ~50 |
+| Backend: drift computation in DTO | Modified | ~30 |
+| Frontend: `ChainGraphView.vue` | New | ~200 |
+| Frontend: `ForceGraphCanvas.vue` | New | ~300 |
+| Frontend: `ChainDetailPanel.vue` | New | ~250 |
+| Frontend: `ChainFilterToolbar.vue` | New | ~80 |
+| Frontend: `ChainLegend.vue` | New | ~60 |
+| Frontend: `ChainEmptyState.vue` | New | ~80 |
+| Frontend: router + nav link | Modified | ~10 |
+| Tests: backend | New | ~250 |
+| Tests: frontend | New | ~150 |
+| **Total** | | **~1,610** |
+
+## 9. Forward context (for 3C.3, 3C.4)
+
+### 9.1 Cross-engagement view (3C.3)
+
+- The subgraph endpoint is scoped by `engagement_id` in 3C.2. In 3C.3, a `null` or omitted `engagement_id` returns user-wide cross-engagement data.
+- Swim lane layout by MITRE phase becomes an optional layout mode toggle on the toolbar.
+- If cross-engagement scale exceeds what `force-graph` handles comfortably even with max_nodes filtering, server-side node clustering (grouping findings by host or phase into aggregate nodes) is the next lever.
+
+### 9.2 Cypher DSL (3C.4)
+
+- The graph view gains a query editor panel (CodeMirror + Cypher mode) where users type queries. Results highlight matching subgraphs in the existing force-graph canvas.
+- No architectural changes needed — queries return canonical graph-json, which feeds through the same `to_force_graph()` adapter.
From 1132ad41b83c042173f8052662e8ac348e2857bd Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:42:27 -0400
Subject: [PATCH 02/18] docs: add Phase 3C.2 attack chain graph view
implementation plan
15 tasks covering backend (subgraph endpoint, curation PATCH, drift DTO),
frontend (force-graph canvas, detail panel, filters, legend, empty state),
and browser verification.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...-04-13-phase3c2-attack-chain-graph-view.md | 1865 +++++++++++++++++
1 file changed, 1865 insertions(+)
create mode 100644 docs/superpowers/plans/2026-04-13-phase3c2-attack-chain-graph-view.md
diff --git a/docs/superpowers/plans/2026-04-13-phase3c2-attack-chain-graph-view.md b/docs/superpowers/plans/2026-04-13-phase3c2-attack-chain-graph-view.md
new file mode 100644
index 0000000..88b0dd1
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-13-phase3c2-attack-chain-graph-view.md
@@ -0,0 +1,1865 @@
+# Phase 3C.2: Attack Chain Graph View — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an interactive per-engagement attack chain graph visualization to the web dashboard, with edge curation (confirm/reject), MITRE ATT&CK phase coloring, and server-side filtering for scale.
+
+**Architecture:** Standalone Vue page at `/engagements/:id/chain` wraps `force-graph` (vasturiano). Backend serves filtered subgraphs via a new `GET /api/chain/subgraph` endpoint that caps nodes and filters by severity/status. Edge curation uses `PATCH /api/chain/relations/:id`. No new database tables — builds on 3C.1 models.
+
+**Tech Stack:** FastAPI, SQLAlchemy async, Vue 3, PrimeVue, `force-graph` (vasturiano), TanStack Query
+
+**Spec:** `docs/superpowers/specs/2026-04-13-phase3c2-attack-chain-graph-view-design.md`
+
+---
+
+## File Map
+
+### Backend (new/modified)
+
+| File | Action | Responsibility |
+|------|--------|---------------|
+| `packages/web/backend/app/routes/chain.py` | Modify | Add `GET /api/chain/subgraph` and `PATCH /api/chain/relations/{relation_id}` endpoints |
+| `packages/web/backend/app/services/chain_service.py` | Modify | Add `subgraph_for_engagement()` and `update_relation_status()` methods |
+| `packages/web/backend/app/services/chain_dto.py` | Modify | Add `relation_to_link_dict()` for force-graph link shape with drift computation |
+| `packages/web/backend/tests/test_chain_subgraph.py` | Create | Tests for subgraph endpoint filtering, capping, neighborhood, drift |
+| `packages/web/backend/tests/test_chain_curation.py` | Create | Tests for relation PATCH (valid transitions, invalid status, auth scoping) |
+
+### Frontend (new/modified)
+
+| File | Action | Responsibility |
+|------|--------|---------------|
+| `packages/web/frontend/src/views/ChainGraphView.vue` | Create | Page component — data fetching, filter state, layout orchestration |
+| `packages/web/frontend/src/components/ForceGraphCanvas.vue` | Create | Wrapper around `force-graph` — rendering config, custom draw callbacks, interaction events |
+| `packages/web/frontend/src/components/ChainDetailPanel.vue` | Create | Right drawer — node details, edge details with reasons, curation buttons |
+| `packages/web/frontend/src/components/ChainFilterToolbar.vue` | Create | Severity/status toggle buttons |
+| `packages/web/frontend/src/components/ChainLegend.vue` | Create | Bottom bar — severity color key, edge style key, node count |
+| `packages/web/frontend/src/components/ChainEmptyState.vue` | Create | Empty state + rebuild progress polling |
+| `packages/web/frontend/src/router/index.ts` | Modify | Add `/engagements/:id/chain` route |
+| `packages/web/frontend/src/views/EngagementDetailView.vue` | Modify | Add "View Attack Chain" button |
+
+---
+
+## Task 1: Backend — `relation_to_link_dict` DTO with drift computation
+
+**Files:**
+- Modify: `packages/web/backend/app/services/chain_dto.py`
+
+This task adds the conversion function that produces the force-graph link shape with inline drift computation. All subsequent backend tasks depend on this.
+
+- [ ] **Step 1: Write the `relation_to_link_dict` function**
+
+Add to `packages/web/backend/app/services/chain_dto.py`:
+
+```python
+def relation_to_link_dict(relation: FindingRelation) -> dict[str, Any]:
+ """Convert a CLI ``FindingRelation`` to a force-graph link dict.
+
+ Includes drift detection: if the relation has status USER_CONFIRMED
+ and the current reasons differ from the confirmed_at_reasons snapshot,
+ drift is True.
+ """
+ status_value = (
+ relation.status.value
+ if hasattr(relation.status, "value")
+ else str(relation.status)
+ )
+
+ # Drift: true if user confirmed but reasons have since changed
+ drift = False
+ if status_value == "user_confirmed" and relation.confirmed_at_reasons is not None:
+ current_rules = sorted(r.rule for r in relation.reasons)
+ confirmed_rules = sorted(r.rule for r in relation.confirmed_at_reasons)
+ drift = current_rules != confirmed_rules
+
+ return {
+ "id": relation.id,
+ "source": relation.source_finding_id,
+ "target": relation.target_finding_id,
+ "value": relation.weight,
+ "status": status_value,
+ "drift": drift,
+ "reasons": [r.rule for r in relation.reasons],
+ "relation_type": relation.llm_relation_type,
+ "rationale": relation.llm_rationale,
+ }
+```
+
+- [ ] **Step 2: Verify the module still imports cleanly**
+
+Run: `cd packages/web/backend && python -c "from app.services.chain_dto import relation_to_link_dict; print('OK')"`
+Expected: `OK`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/web/backend/app/services/chain_dto.py
+git commit -m "feat(chain): add relation_to_link_dict DTO with drift detection"
+```
+
+---
+
+## Task 2: Backend — `subgraph_for_engagement` service method
+
+**Files:**
+- Modify: `packages/web/backend/app/services/chain_service.py`
+
+Adds the service method that queries the store for findings + relations scoped to an engagement, applies severity/status filters, enforces max_nodes cap, and returns the force-graph-shaped response.
+
+- [ ] **Step 1: Add imports at top of chain_service.py**
+
+Add these imports to the existing import block:
+
+```python
+from app.services.chain_dto import relation_to_link_dict
+```
+
+- [ ] **Step 2: Add `subgraph_for_engagement` method to `ChainService`**
+
+```python
+ async def subgraph_for_engagement(
+ self,
+ session: AsyncSession,
+ *,
+ user_id: uuid.UUID,
+ engagement_id: str,
+ severities: set[str] | None = None,
+ statuses: set[str] | None = None,
+ max_nodes: int = 500,
+ seed_finding_id: str | None = None,
+ hops: int = 2,
+ format: str = "force-graph",
+ ) -> dict[str, Any]:
+ """Build a filtered subgraph for one engagement.
+
+ Returns a dict with 'graph' (force-graph or canonical shape)
+ and 'meta' (total_findings, rendered_findings, filtered, generation).
+ """
+ from opentools.chain.config import get_chain_config
+ from opentools.chain.query.graph_cache import GraphCache
+ from opentools.chain.query.adapters import to_canonical_json, to_force_graph
+ from opentools.chain.types import RelationStatus
+
+ from sqlalchemy import select, func
+ from app.models import Finding, ChainFindingRelation
+
+ store = chain_store_from_session(session)
+ await store.initialize()
+
+ # Count total findings in engagement (for meta)
+ total_stmt = select(func.count()).select_from(Finding).where(
+ Finding.engagement_id == engagement_id,
+ Finding.user_id == user_id,
+ Finding.deleted_at.is_(None),
+ )
+ total_result = await session.execute(total_stmt)
+ total_findings = total_result.scalar() or 0
+
+ # Fetch findings for this engagement, applying severity filter
+ finding_stmt = select(Finding).where(
+ Finding.engagement_id == engagement_id,
+ Finding.user_id == user_id,
+ Finding.deleted_at.is_(None),
+ )
+ if severities:
+ finding_stmt = finding_stmt.where(Finding.severity.in_(severities))
+ finding_stmt = finding_stmt.limit(max_nodes)
+
+ finding_result = await session.execute(finding_stmt)
+ findings = list(finding_result.scalars().all())
+ finding_ids = {f.id for f in findings}
+
+ if not finding_ids:
+ empty_graph = {"nodes": [], "links": []} if format == "force-graph" else {"schema_version": "1.0", "nodes": [], "edges": [], "metadata": {}}
+ return {
+ "graph": empty_graph,
+ "meta": {
+ "total_findings": total_findings,
+ "rendered_findings": 0,
+ "filtered": bool(severities) or total_findings > max_nodes,
+ "generation": 0,
+ },
+ }
+
+ # Default status filter
+ if statuses is None:
+ statuses = {"auto_confirmed", "user_confirmed", "candidate"}
+
+ # Fetch relations where both endpoints are in finding_ids
+ rel_stmt = select(ChainFindingRelation).where(
+ ChainFindingRelation.user_id == user_id,
+ ChainFindingRelation.source_finding_id.in_(finding_ids),
+ ChainFindingRelation.target_finding_id.in_(finding_ids),
+ ChainFindingRelation.status.in_(statuses),
+ )
+ rel_result = await session.execute(rel_stmt)
+ relations_orm = list(rel_result.scalars().all())
+
+ # Build nodes
+ nodes = [
+ {
+ "id": f.id,
+ "name": f.title,
+ "severity": f.severity,
+ "tool": f.tool,
+ "phase": f.phase,
+ }
+ for f in findings
+ ]
+
+ # Build links via DTO
+ from opentools.chain.models import FindingRelation as DomainRelation, RelationReason
+ from opentools.chain.stores.postgres_async import _orm_to_relation
+
+ links = [
+ relation_to_link_dict(_orm_to_relation(r))
+ for r in relations_orm
+ ]
+
+ # Get latest generation from most recent linker run
+ from app.models import ChainLinkerRun
+ gen_stmt = (
+ select(ChainLinkerRun.generation)
+ .where(ChainLinkerRun.user_id == user_id)
+ .order_by(ChainLinkerRun.started_at.desc())
+ .limit(1)
+ )
+ gen_result = await session.execute(gen_stmt)
+ generation = gen_result.scalar() or 0
+
+ if format == "force-graph":
+ graph = {"nodes": nodes, "links": links}
+ else:
+ graph = {
+ "schema_version": "1.0",
+ "nodes": [{"id": n["id"], "type": "finding", "severity": n["severity"], "tool": n["tool"], "title": n["name"]} for n in nodes],
+ "edges": [{"source": l["source"], "target": l["target"], "weight": l["value"], "status": l["status"], "symmetric": False, "reasons": l["reasons"], "relation_type": l["relation_type"], "rationale": l["rationale"]} for l in links],
+ "metadata": {"generation": generation, "max_weight": max((l["value"] for l in links), default=0)},
+ }
+
+ return {
+ "graph": graph,
+ "meta": {
+ "total_findings": total_findings,
+ "rendered_findings": len(findings),
+ "filtered": bool(severities) or len(findings) < total_findings,
+ "generation": generation,
+ },
+ }
+```
+
+- [ ] **Step 3: Verify the module still imports**
+
+Run: `cd packages/web/backend && python -c "from app.services.chain_service import ChainService; print('OK')"`
+Expected: `OK`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/backend/app/services/chain_service.py
+git commit -m "feat(chain): add subgraph_for_engagement service method"
+```
+
+---
+
+## Task 3: Backend — `update_relation_status` service method
+
+**Files:**
+- Modify: `packages/web/backend/app/services/chain_service.py`
+
+Adds the service method for edge curation — updates relation status to `user_confirmed` or `user_rejected`, snapshots `confirmed_at_reasons_json` on confirm.
+
+- [ ] **Step 1: Add `update_relation_status` method to `ChainService`**
+
+```python
+ async def update_relation_status(
+ self,
+ session: AsyncSession,
+ *,
+ user_id: uuid.UUID,
+ relation_id: str,
+ new_status: str,
+ ) -> dict[str, Any] | None:
+ """Update a relation's status for edge curation.
+
+ Only 'user_confirmed' and 'user_rejected' are valid.
+ On confirm, snapshots current reasons_json into confirmed_at_reasons_json.
+ Returns the updated relation dict, or None if not found.
+ """
+ from sqlalchemy import select, update
+ from app.models import ChainFindingRelation
+ from datetime import datetime, timezone
+ from opentools.chain.stores.postgres_async import _orm_to_relation
+ from app.services.chain_dto import relation_to_dict
+
+ # Fetch the relation, scoped to user
+ stmt = select(ChainFindingRelation).where(
+ ChainFindingRelation.id == relation_id,
+ ChainFindingRelation.user_id == user_id,
+ )
+ result = await session.execute(stmt)
+ relation = result.scalar_one_or_none()
+ if relation is None:
+ return None
+
+ # Update status
+ relation.status = new_status
+ relation.updated_at = datetime.now(timezone.utc)
+
+ # On confirm, snapshot current reasons for drift detection
+ if new_status == "user_confirmed":
+ relation.confirmed_at_reasons_json = relation.reasons_json
+
+ session.add(relation)
+ await session.commit()
+ await session.refresh(relation)
+
+ return relation_to_dict(_orm_to_relation(relation))
+```
+
+- [ ] **Step 2: Verify import**
+
+Run: `cd packages/web/backend && python -c "from app.services.chain_service import ChainService; print('OK')"`
+Expected: `OK`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/web/backend/app/services/chain_service.py
+git commit -m "feat(chain): add update_relation_status for edge curation"
+```
+
+---
+
+## Task 4: Backend — Subgraph and curation route endpoints
+
+**Files:**
+- Modify: `packages/web/backend/app/routes/chain.py`
+
+Adds `GET /api/chain/subgraph` and `PATCH /api/chain/relations/{relation_id}`.
+
+- [ ] **Step 1: Add new Pydantic models for the endpoints**
+
+Add to `packages/web/backend/app/routes/chain.py`, after the existing model classes:
+
+```python
+class SubgraphMeta(BaseModel):
+ total_findings: int
+ rendered_findings: int
+ filtered: bool
+ generation: int
+
+
+class SubgraphResponse(BaseModel):
+ graph: dict
+ meta: SubgraphMeta
+
+
+class RelationStatusUpdate(BaseModel):
+ status: str
+```
+
+- [ ] **Step 2: Add the subgraph endpoint**
+
+```python
+@router.get("/subgraph", response_model=SubgraphResponse)
+async def get_subgraph(
+ engagement_id: str,
+ severity: Optional[str] = None,
+ status: Optional[str] = None,
+ max_nodes: int = 500,
+ seed_finding_id: Optional[str] = None,
+ hops: int = 2,
+ format: str = "force-graph",
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(get_current_user),
+ service: ChainService = Depends(get_chain_service),
+) -> SubgraphResponse:
+ severities = set(severity.split(",")) if severity else None
+ statuses = set(status.split(",")) if status else None
+
+ result = await service.subgraph_for_engagement(
+ db,
+ user_id=user.id,
+ engagement_id=engagement_id,
+ severities=severities,
+ statuses=statuses,
+ max_nodes=max_nodes,
+ seed_finding_id=seed_finding_id,
+ hops=hops,
+ format=format,
+ )
+ return SubgraphResponse(
+ graph=result["graph"],
+ meta=SubgraphMeta(**result["meta"]),
+ )
+```
+
+- [ ] **Step 3: Add the relation curation endpoint**
+
+```python
+@router.patch("/relations/{relation_id}")
+async def update_relation(
+ relation_id: str,
+ body: RelationStatusUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(get_current_user),
+ service: ChainService = Depends(get_chain_service),
+):
+ valid_statuses = {"user_confirmed", "user_rejected"}
+ if body.status not in valid_statuses:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"status must be one of: {', '.join(valid_statuses)}",
+ )
+
+ result = await service.update_relation_status(
+ db, user_id=user.id, relation_id=relation_id, new_status=body.status,
+ )
+ if result is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="relation not found")
+ return result
+```
+
+- [ ] **Step 4: Verify the app starts**
+
+Run: `cd packages/web/backend && python -c "from app.main import app; print('OK')"`
+Expected: `OK`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/backend/app/routes/chain.py
+git commit -m "feat(chain): add subgraph and relation curation endpoints"
+```
+
+---
+
+## Task 5: Backend — Subgraph endpoint tests
+
+**Files:**
+- Create: `packages/web/backend/tests/test_chain_subgraph.py`
+
+- [ ] **Step 1: Write subgraph endpoint tests**
+
+Create `packages/web/backend/tests/test_chain_subgraph.py`:
+
+```python
+"""Subgraph endpoint tests (Phase 3C.2)."""
+
+import uuid
+from datetime import datetime, timezone
+
+import pytest
+
+from app.models import ChainFindingRelation, Engagement, Finding
+from tests.conftest import test_session_factory
+
+NOW = datetime.now(timezone.utc)
+
+
+async def _get_user_id(auth_client) -> uuid.UUID:
+ eng_resp = await auth_client.post("/api/v1/engagements", json={
+ "name": "_uid_probe", "target": "127.0.0.1", "type": "pentest",
+ })
+ assert eng_resp.status_code == 201
+ eng_id = eng_resp.json()["id"]
+ async with test_session_factory() as session:
+ from sqlalchemy import select
+ from app.models import Engagement as Eng
+ result = await session.execute(select(Eng).where(Eng.id == eng_id))
+ eng = result.scalar_one()
+ return eng.user_id
+
+
+async def _seed_engagement(user_id, eng_id):
+ async with test_session_factory() as session:
+ session.add(Engagement(
+ id=eng_id, user_id=user_id, name="Test", target="10.0.0.0/24",
+ type="pentest", created_at=NOW, updated_at=NOW,
+ ))
+ await session.commit()
+
+
+async def _seed_finding(user_id, eng_id, finding_id, severity="high", phase=None):
+ async with test_session_factory() as session:
+ session.add(Finding(
+ id=finding_id, user_id=user_id, engagement_id=eng_id,
+ tool="nmap", severity=severity, title=f"Finding {finding_id}",
+ phase=phase, created_at=NOW,
+ ))
+ await session.commit()
+
+
+async def _seed_relation(user_id, src_id, tgt_id, rel_id, status="auto_confirmed", weight=0.8):
+ async with test_session_factory() as session:
+ session.add(ChainFindingRelation(
+ id=rel_id, user_id=user_id, source_finding_id=src_id,
+ target_finding_id=tgt_id, weight=weight, status=status,
+ symmetric=False, created_at=NOW, updated_at=NOW,
+ ))
+ await session.commit()
+
+
+@pytest.mark.asyncio
+async def test_subgraph_empty_engagement(auth_client):
+ """Engagement with no findings returns empty graph."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_engagement(user_id, "eng-empty")
+
+ resp = await auth_client.get("/api/chain/subgraph?engagement_id=eng-empty")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["graph"]["nodes"] == []
+ assert data["graph"]["links"] == []
+ assert data["meta"]["total_findings"] == 0
+ assert data["meta"]["rendered_findings"] == 0
+
+
+@pytest.mark.asyncio
+async def test_subgraph_returns_nodes_and_links(auth_client):
+ """Seeded findings and relations appear in subgraph response."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_engagement(user_id, "eng-sub")
+ await _seed_finding(user_id, "eng-sub", "f-1", severity="critical")
+ await _seed_finding(user_id, "eng-sub", "f-2", severity="high")
+ await _seed_relation(user_id, "f-1", "f-2", "rel-1")
+
+ resp = await auth_client.get("/api/chain/subgraph?engagement_id=eng-sub")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["graph"]["nodes"]) == 2
+ assert len(data["graph"]["links"]) == 1
+ link = data["graph"]["links"][0]
+ assert link["id"] == "rel-1"
+ assert link["source"] == "f-1"
+ assert link["target"] == "f-2"
+ assert "drift" in link
+
+
+@pytest.mark.asyncio
+async def test_subgraph_severity_filter(auth_client):
+ """Severity filter excludes non-matching findings."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_engagement(user_id, "eng-sev")
+ await _seed_finding(user_id, "eng-sev", "f-crit", severity="critical")
+ await _seed_finding(user_id, "eng-sev", "f-low", severity="low")
+
+ resp = await auth_client.get("/api/chain/subgraph?engagement_id=eng-sev&severity=critical")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["graph"]["nodes"]) == 1
+ assert data["graph"]["nodes"][0]["severity"] == "critical"
+
+
+@pytest.mark.asyncio
+async def test_subgraph_status_filter(auth_client):
+ """Status filter excludes non-matching relations."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_engagement(user_id, "eng-stat")
+ await _seed_finding(user_id, "eng-stat", "f-a")
+ await _seed_finding(user_id, "eng-stat", "f-b")
+ await _seed_relation(user_id, "f-a", "f-b", "rel-conf", status="auto_confirmed")
+ await _seed_relation(user_id, "f-b", "f-a", "rel-cand", status="candidate")
+
+ # Only auto_confirmed
+ resp = await auth_client.get(
+ "/api/chain/subgraph?engagement_id=eng-stat&status=auto_confirmed"
+ )
+ data = resp.json()
+ assert len(data["graph"]["links"]) == 1
+ assert data["graph"]["links"][0]["status"] == "auto_confirmed"
+
+
+@pytest.mark.asyncio
+async def test_subgraph_max_nodes_cap(auth_client):
+ """max_nodes caps the number of returned findings."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_engagement(user_id, "eng-cap")
+ for i in range(10):
+ await _seed_finding(user_id, "eng-cap", f"f-cap-{i}")
+
+ resp = await auth_client.get("/api/chain/subgraph?engagement_id=eng-cap&max_nodes=3")
+ data = resp.json()
+ assert len(data["graph"]["nodes"]) == 3
+ assert data["meta"]["total_findings"] == 10
+ assert data["meta"]["filtered"] is True
+
+
+@pytest.mark.asyncio
+async def test_subgraph_unauthenticated(client):
+ """Unauthenticated request returns 401."""
+ resp = await client.get("/api/chain/subgraph?engagement_id=eng-x")
+ assert resp.status_code == 401
+```
+
+- [ ] **Step 2: Run the tests**
+
+Run: `cd packages/web/backend && python -m pytest tests/test_chain_subgraph.py -v`
+Expected: all tests PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/web/backend/tests/test_chain_subgraph.py
+git commit -m "test(chain): subgraph endpoint tests — filters, cap, auth"
+```
+
+---
+
+## Task 6: Backend — Curation endpoint tests
+
+**Files:**
+- Create: `packages/web/backend/tests/test_chain_curation.py`
+
+- [ ] **Step 1: Write curation endpoint tests**
+
+Create `packages/web/backend/tests/test_chain_curation.py`:
+
+```python
+"""Relation curation (PATCH) endpoint tests (Phase 3C.2)."""
+
+import uuid
+from datetime import datetime, timezone
+
+import pytest
+
+from app.models import ChainFindingRelation, Engagement, Finding
+from tests.conftest import test_session_factory
+
+NOW = datetime.now(timezone.utc)
+
+
+async def _get_user_id(auth_client) -> uuid.UUID:
+ eng_resp = await auth_client.post("/api/v1/engagements", json={
+ "name": "_uid_probe", "target": "127.0.0.1", "type": "pentest",
+ })
+ assert eng_resp.status_code == 201
+ eng_id = eng_resp.json()["id"]
+ async with test_session_factory() as session:
+ from sqlalchemy import select
+ from app.models import Engagement as Eng
+ result = await session.execute(select(Eng).where(Eng.id == eng_id))
+ eng = result.scalar_one()
+ return eng.user_id
+
+
+async def _seed_with_relation(user_id, rel_id="rel-cur", status="candidate"):
+ async with test_session_factory() as session:
+ session.add(Engagement(
+ id="eng-cur", user_id=user_id, name="Test", target="10.0.0.1",
+ type="pentest", created_at=NOW, updated_at=NOW,
+ ))
+ await session.flush()
+ session.add(Finding(
+ id="f-cur-1", user_id=user_id, engagement_id="eng-cur",
+ tool="nmap", severity="high", title="Finding 1", created_at=NOW,
+ ))
+ session.add(Finding(
+ id="f-cur-2", user_id=user_id, engagement_id="eng-cur",
+ tool="nuclei", severity="medium", title="Finding 2", created_at=NOW,
+ ))
+ await session.flush()
+ session.add(ChainFindingRelation(
+ id=rel_id, user_id=user_id, source_finding_id="f-cur-1",
+ target_finding_id="f-cur-2", weight=0.75, status=status,
+ symmetric=False, reasons_json=b'[{"rule":"shared_strong_entity","weight_contribution":0.5,"idf_factor":null,"details":{}}]',
+ created_at=NOW, updated_at=NOW,
+ ))
+ await session.commit()
+
+
+@pytest.mark.asyncio
+async def test_confirm_candidate(auth_client):
+ """Confirming a candidate relation succeeds."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_with_relation(user_id, "rel-c1", status="candidate")
+
+ resp = await auth_client.patch(
+ "/api/chain/relations/rel-c1",
+ json={"status": "user_confirmed"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "user_confirmed"
+
+
+@pytest.mark.asyncio
+async def test_reject_candidate(auth_client):
+ """Rejecting a candidate relation succeeds."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_with_relation(user_id, "rel-c2", status="candidate")
+
+ resp = await auth_client.patch(
+ "/api/chain/relations/rel-c2",
+ json={"status": "user_rejected"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "user_rejected"
+
+
+@pytest.mark.asyncio
+async def test_toggle_confirmed_to_rejected(auth_client):
+ """User can change from confirmed to rejected."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_with_relation(user_id, "rel-c3", status="user_confirmed")
+
+ resp = await auth_client.patch(
+ "/api/chain/relations/rel-c3",
+ json={"status": "user_rejected"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "user_rejected"
+
+
+@pytest.mark.asyncio
+async def test_invalid_status_returns_422(auth_client):
+ """Setting auto_confirmed via PATCH returns 422."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_with_relation(user_id, "rel-c4")
+
+ resp = await auth_client.patch(
+ "/api/chain/relations/rel-c4",
+ json={"status": "auto_confirmed"},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_nonexistent_relation_returns_404(auth_client):
+ """Patching a nonexistent relation returns 404."""
+ resp = await auth_client.patch(
+ "/api/chain/relations/rel-does-not-exist",
+ json={"status": "user_confirmed"},
+ )
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_confirm_snapshots_reasons(auth_client):
+ """Confirming snapshots reasons_json into confirmed_at_reasons_json."""
+ user_id = await _get_user_id(auth_client)
+ await _seed_with_relation(user_id, "rel-c5", status="candidate")
+
+ await auth_client.patch(
+ "/api/chain/relations/rel-c5",
+ json={"status": "user_confirmed"},
+ )
+
+ # Verify in DB
+ async with test_session_factory() as session:
+ from sqlalchemy import select
+ result = await session.execute(
+ select(ChainFindingRelation).where(ChainFindingRelation.id == "rel-c5")
+ )
+ rel = result.scalar_one()
+ assert rel.confirmed_at_reasons_json is not None
+ assert rel.confirmed_at_reasons_json == rel.reasons_json
+
+
+@pytest.mark.asyncio
+async def test_unauthenticated_returns_401(client):
+ """Unauthenticated curation request returns 401."""
+ resp = await client.patch(
+ "/api/chain/relations/rel-x",
+ json={"status": "user_confirmed"},
+ )
+ assert resp.status_code == 401
+```
+
+- [ ] **Step 2: Run the tests**
+
+Run: `cd packages/web/backend && python -m pytest tests/test_chain_curation.py -v`
+Expected: all tests PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/web/backend/tests/test_chain_curation.py
+git commit -m "test(chain): curation endpoint tests — transitions, validation, auth"
+```
+
+---
+
+## Task 7: Frontend — Install `force-graph` and add route
+
+**Files:**
+- Modify: `packages/web/frontend/package.json` (via npm)
+- Modify: `packages/web/frontend/src/router/index.ts`
+- Modify: `packages/web/frontend/src/views/EngagementDetailView.vue`
+
+- [ ] **Step 1: Install force-graph**
+
+Run: `cd packages/web/frontend && npm install force-graph`
+
+- [ ] **Step 2: Add the chain route to router**
+
+In `packages/web/frontend/src/router/index.ts`, add after the `finding-detail` route:
+
+```typescript
+ { path: '/engagements/:id/chain', name: 'engagement-chain', component: () => import('@/views/ChainGraphView.vue') },
+```
+
+- [ ] **Step 3: Add "View Attack Chain" button to EngagementDetailView**
+
+In `packages/web/frontend/src/views/EngagementDetailView.vue`, add a button next to the existing Delete button in the header:
+
+Find the `
-
+
+
+
+
From b6d18d31dbd2751d693fa4dc03dafa8cd7545667 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:57:26 -0400
Subject: [PATCH 10/18] =?UTF-8?q?feat(frontend):=20ChainEmptyState=20?=
=?UTF-8?q?=E2=80=94=20rebuild=20trigger=20with=20progress=20polling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/ChainEmptyState.vue | 72 +++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 packages/web/frontend/src/components/ChainEmptyState.vue
diff --git a/packages/web/frontend/src/components/ChainEmptyState.vue b/packages/web/frontend/src/components/ChainEmptyState.vue
new file mode 100644
index 0000000..e16a88f
--- /dev/null
+++ b/packages/web/frontend/src/components/ChainEmptyState.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
No attack chain data yet
+
Run chain analysis to extract relationships between findings.
+
+
+
+
Analyzing findings...
+
+
+
From 4131b4d5f50e4310a27b5ce640b87792b078ce63 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:57:37 -0400
Subject: [PATCH 11/18] =?UTF-8?q?feat(frontend):=20ChainLegend=20=E2=80=94?=
=?UTF-8?q?=20severity=20colors,=20edge=20styles,=20node=20count?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../frontend/src/components/ChainLegend.vue | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 packages/web/frontend/src/components/ChainLegend.vue
diff --git a/packages/web/frontend/src/components/ChainLegend.vue b/packages/web/frontend/src/components/ChainLegend.vue
new file mode 100644
index 0000000..f04f703
--- /dev/null
+++ b/packages/web/frontend/src/components/ChainLegend.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+ {{ s.label }}
+
+
|
+
── Confirmed
+
╌╌ Candidate
+
+
+ Showing {{ renderedCount }} of {{ totalCount }}
+
+
+
From 88e81b3f592ba8b07727566c34995d19e981c105 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:58:12 -0400
Subject: [PATCH 12/18] =?UTF-8?q?feat(frontend):=20ChainDetailPanel=20?=
=?UTF-8?q?=E2=80=94=20node/edge=20details=20with=20curation=20buttons?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/ChainDetailPanel.vue | 180 ++++++++++++++++++
1 file changed, 180 insertions(+)
create mode 100644 packages/web/frontend/src/components/ChainDetailPanel.vue
diff --git a/packages/web/frontend/src/components/ChainDetailPanel.vue b/packages/web/frontend/src/components/ChainDetailPanel.vue
new file mode 100644
index 0000000..277f3a0
--- /dev/null
+++ b/packages/web/frontend/src/components/ChainDetailPanel.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+ {{ selectedNode ? 'Node Details' : 'Edge Details' }}
+
+
+
+
+
+
+
+
+ {{ selectedNode.name }}
+
+
+
+
+
+ Tool: {{ selectedNode.tool }}
+
+
+ Phase: {{ selectedNode.phase }}
+
+
+ Neighbors: {{ selectedNode.neighborCount }}
+
+
+
+
+
+
+
+
+
+
Source
+
+ {{ findNode(selectedLink.source)?.name ?? (typeof selectedLink.source === 'string' ? selectedLink.source : selectedLink.source.id) }}
+
+
+
+
+
Target
+
+ {{ findNode(selectedLink.target)?.name ?? (typeof selectedLink.target === 'string' ? selectedLink.target : selectedLink.target.id) }}
+
+
+
+
+
+
+ Weight: {{ selectedLink.value.toFixed(2) }}
+
+
+
+
+
+
+
+
+ Reasoning changed since you confirmed this edge.
+
+
+
+
+
+
+
+
LLM Rationale
+
+ {{ selectedLink.rationale }}
+
+
+
+
+
+
+
+
+
+
+
From 9c08aacf01cb248379f16ea9db152ab55a742bdf Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:58:21 -0400
Subject: [PATCH 13/18] =?UTF-8?q?feat(frontend):=20ChainFilterToolbar=20?=
=?UTF-8?q?=E2=80=94=20severity=20and=20status=20toggles?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/ChainFilterToolbar.vue | 122 ++++++++++++++++++
1 file changed, 122 insertions(+)
create mode 100644 packages/web/frontend/src/components/ChainFilterToolbar.vue
diff --git a/packages/web/frontend/src/components/ChainFilterToolbar.vue b/packages/web/frontend/src/components/ChainFilterToolbar.vue
new file mode 100644
index 0000000..b8f9f93
--- /dev/null
+++ b/packages/web/frontend/src/components/ChainFilterToolbar.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
From a9deae77aa2ad4d362a7cfc8c34bec916a841690 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 02:59:02 -0400
Subject: [PATCH 14/18] =?UTF-8?q?feat(frontend):=20ChainFilterToolbar=20?=
=?UTF-8?q?=E2=80=94=20severity=20and=20status=20toggles?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../src/components/ChainFilterToolbar.vue | 153 +++++-------------
1 file changed, 43 insertions(+), 110 deletions(-)
diff --git a/packages/web/frontend/src/components/ChainFilterToolbar.vue b/packages/web/frontend/src/components/ChainFilterToolbar.vue
index b8f9f93..4c2fd5e 100644
--- a/packages/web/frontend/src/components/ChainFilterToolbar.vue
+++ b/packages/web/frontend/src/components/ChainFilterToolbar.vue
@@ -1,122 +1,55 @@
-
-
-
-
-
-
+
+
+
+ |
+
+
+
+
From 26adb79306e867a9382ebce17bce0eacc1f25c25 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 03:00:18 -0400
Subject: [PATCH 15/18] =?UTF-8?q?feat(frontend):=20ForceGraphCanvas=20?=
=?UTF-8?q?=E2=80=94=20force-graph=20wrapper=20with=20custom=20rendering?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Custom node rendering (severity colors, MITRE pills, selection rings),
custom edge rendering (status-based styles, drift badges, arrowheads),
position-preserving data updates.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../src/components/ForceGraphCanvas.vue | 309 ++++++++++++++++++
1 file changed, 309 insertions(+)
create mode 100644 packages/web/frontend/src/components/ForceGraphCanvas.vue
diff --git a/packages/web/frontend/src/components/ForceGraphCanvas.vue b/packages/web/frontend/src/components/ForceGraphCanvas.vue
new file mode 100644
index 0000000..95f6214
--- /dev/null
+++ b/packages/web/frontend/src/components/ForceGraphCanvas.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
From d0ea092ce3b1cd6ca2b724aebb7b8b121b1321b3 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 03:01:22 -0400
Subject: [PATCH 16/18] =?UTF-8?q?feat(frontend):=20ChainGraphView=20?=
=?UTF-8?q?=E2=80=94=20page=20component=20with=20data=20fetching,=20curati?=
=?UTF-8?q?on,=20expansion?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../web/frontend/src/views/ChainGraphView.vue | 225 ++++++++++++++++++
1 file changed, 225 insertions(+)
create mode 100644 packages/web/frontend/src/views/ChainGraphView.vue
diff --git a/packages/web/frontend/src/views/ChainGraphView.vue b/packages/web/frontend/src/views/ChainGraphView.vue
new file mode 100644
index 0000000..fba54b9
--- /dev/null
+++ b/packages/web/frontend/src/views/ChainGraphView.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
+
{{ engagement?.name ?? 'Attack Chain' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 8067e20ccc2c1c952825189d8dbd031975fe461f Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 03:03:05 -0400
Subject: [PATCH 17/18] fix: use HTTP_422_UNPROCESSABLE_CONTENT (non-deprecated
constant)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
packages/web/backend/app/routes/chain.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/backend/app/routes/chain.py b/packages/web/backend/app/routes/chain.py
index d12e5b5..669bc94 100644
--- a/packages/web/backend/app/routes/chain.py
+++ b/packages/web/backend/app/routes/chain.py
@@ -309,7 +309,7 @@ async def update_relation(
valid_statuses = {"user_confirmed", "user_rejected"}
if body.status not in valid_statuses:
raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"status must be one of: {', '.join(valid_statuses)}",
)
From b441a0be6af696adff3ca1a64e572af52fa10229 Mon Sep 17 00:00:00 2001
From: Emperiusm
Date: Mon, 13 Apr 2026 03:05:24 -0400
Subject: [PATCH 18/18] fix(frontend): ForceGraphCanvas type compatibility with
force-graph
Use `new ForceGraph()` constructor (matches type defs), `undefined`
instead of `null` for fx/fy (matches NodeObject interface).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../web/frontend/src/components/ForceGraphCanvas.vue | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/web/frontend/src/components/ForceGraphCanvas.vue b/packages/web/frontend/src/components/ForceGraphCanvas.vue
index 95f6214..89b15a9 100644
--- a/packages/web/frontend/src/components/ForceGraphCanvas.vue
+++ b/packages/web/frontend/src/components/ForceGraphCanvas.vue
@@ -10,8 +10,8 @@ interface GraphNode {
phase: string | null
x?: number
y?: number
- fx?: number | null
- fy?: number | null
+ fx?: number | undefined
+ fy?: number | undefined
neighborCount?: number
}
@@ -85,7 +85,7 @@ function countConnections(nodeId: string): number {
function initGraph() {
if (!container.value) return
- graph = ForceGraph()(container.value)
+ graph = new ForceGraph(container.value)
.graphData(props.data)
.nodeId('id')
.linkSource('source')
@@ -268,8 +268,8 @@ function updateData(newData: GraphData) {
n.fy = pos.y
// Unpin after short delay to let simulation settle
setTimeout(() => {
- n.fx = null
- n.fy = null
+ n.fx = undefined
+ n.fy = undefined
}, 1000)
}
}