# Phase 8 — Visual Flow Builder (Canvas-Based Route Designer)

## Overview

Replace the form-based composite route editor (Phase 7.4) with a **visual, drag-and-drop
flow canvas** that lets operations and integration teams design composite routes, slot
chains, fallback paths, and data transformations as an interactive node graph — without
writing Elixir code or editing JSON.

Inspired by Mautic's campaign builder (which uses jsPlumb for connections), this builder
uses **jsPlumb Community Edition v6** embedded in a Phoenix LiveView page, bridged via
a LiveView JS Hook.

This phase also lays the canvas infrastructure that Phase 9 (DAG/Conditional Execution)
will build on — flows designed here will drive the DAG execution engine directly.

---

## Why This Phase Comes Before Phase 9

```
Phase 7  → Backend: Slot + Chain + FanoutDispatcher + Merger
Phase 7.4 → Form UI: text-based composite route editor (simple, validates backend)
Phase 8  → Visual Builder: canvas replaces/enhances 7.4, supports DAG flow design
Phase 9  → DAG Execution: backend engine for flows designed in Phase 8
```

Phase 8 must precede Phase 9 because:
- The visual canvas is the **design surface** for DAG flows
- Serializing a drawn graph into a `CompositeRoute` or `DagRoute` struct is done here
- Phase 9 only needs to execute what Phase 8 can express and persist

---

## Node Type Reference

Each node on the canvas represents one concept in the MW-Core composite routing model.

```
┌─────────────────┬────────────────────────────────────────────────────────┐
│ Node Type       │ Represents                                             │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Request         │ Incoming API call — entry point. One per canvas.       │
│                 │ Shows: method, path, tenant                            │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Slot            │ Named logical data group (maps to MwKernel.Slot).     │
│                 │ Shows: slot name, required toggle                      │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Adapter         │ One adapter call within a slot's priority chain.       │
│                 │ Shows: adapter name, priority, timeout, fallback_on    │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Decision        │ Conditional branch on request data or slot result.     │ (Phase 9)
│                 │ Two outputs: YES path (green) / NO path (red)          │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Transform       │ Field mapping / data reshape step (MwTransform).       │
│                 │ Shows: field_map rules, input/output schema preview    │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Merge           │ Converges resolved slot results (MwTransform.Merger).  │
│                 │ Shows: merge_strategy selector                         │
├─────────────────┼────────────────────────────────────────────────────────┤
│ Response        │ Output / sink. One per canvas.                         │
│                 │ Shows: HTTP status rules, response envelope preview    │
└─────────────────┴────────────────────────────────────────────────────────┘
```

---

## Connection Type Reference

Connections are typed and visually differentiated:

```
──────────────  Primary (solid blue)
                Normal execution flow — "call this next"

- - - - - - -  Fallback (dashed orange)
                Triggered when fallback_on conditions fire on source adapter

━━━━━━━━━━━━━  Required (solid green, bold)
                Slot declared as required: true

·············  Dependency (dotted grey)                          (Phase 9)
                Slot-to-slot data dependency: Slot B uses Slot A's output

────── ✓ ─────  Decision YES (solid green)                       (Phase 9)
────── ✗ ─────  Decision NO (solid red)                          (Phase 9)
```

---

## Canvas Layout — Reference Diagram

This is how a typical composite route (CRM + DB fallback + banking) looks on the canvas:

```
         ┌─────────────────────────────┐
         │  ► Incoming Request          │   [Request Node]
         │  GET /api/v1/customer-view   │
         └──────────┬──────────────────┘
                    │ primary
          ┌─────────┴─────────┐
          │                   │
  ┌───────▼────────┐  ┌───────▼────────┐
  │ Slot            │  │ Slot            │
  │ customer_profile│  │ account_balance │
  │ (optional)      │  │ (required)      │
  └───┬─────────────┘  └───────┬────────┘
      │ primary                │ primary
  ┌───▼──────────┐         ┌───▼──────────────┐
  │ crm_api      │         │ banking_legacy    │
  │ priority: 1  │         │ priority: 1       │
  │ timeout: 3s  │         │ timeout: 5s       │
  └──────────────┘         └──────────────────┘
      │ fallback
      │ (on: timeout, error, empty)
  ┌───▼──────────┐
  │ local_db     │
  │ priority: 2  │
  │ timeout: 1s  │
  └──────────────┘
          │                   │
          └─────────┬─────────┘
                    │
          ┌─────────▼─────────┐
          │      Merge         │
          │  strategy:         │
          │  deep_merge        │
          └─────────┬─────────┘
                    │
          ┌─────────▼─────────┐
          │     Response       │
          │  200 / 206 / 502   │
          └───────────────────┘
```

---

## Architecture

```
Browser (jsPlumb canvas)          Phoenix LiveView (FlowBuilderLive)
─────────────────────────         ──────────────────────────────────
FlowCanvas JS Hook                FlowBuilderLive.mount/2
  │                                 │
  │  handleEvent("init_canvas")◄────┤  push nodes + connections from
  │                                 │  existing CompositeRoute (if editing)
  │
  │  User drags node from palette
  │  pushEvent("node_added", %{type, x, y}) ──────────────────────────►
  │                                                    handle_event/3
  │                                                    validates node
  │  handleEvent("node_ack", %{id, config}) ◄──────────────────────────
  │  jsPlumb registers node with server-assigned ID
  │
  │  User draws connection
  │  pushEvent("connection_made", %{src, tgt, type}) ─────────────────►
  │                                                    validates edge
  │                                                    (type rules)
  │  handleEvent("connection_ack" | "connection_rejected") ◄───────────
  │
  │  User clicks "Apply"
  │  pushEvent("canvas_save", %{nodes, connections}) ─────────────────►
  │                                                    canvas_to_route/1
  │                                                    persists to ETS
  │                                                    + DB
  │  handleEvent("save_result", %{status, errors}) ◄───────────────────
```

---

## Sub-Phases

---

### Phase 8.0 — JS Infrastructure & jsPlumb Setup

**Goal:** Get jsPlumb v6 bundled into the Phoenix asset pipeline and a working blank
canvas mounted via a LiveView hook.

#### Tasks

**Create `apps/gateway_web/assets/package.json`:**

```json
{
  "dependencies": {
    "@jsplumb/browser-ui": "^6.0.0"
  }
}
```

**Update `apps/gateway_web/assets/js/app.js`** — add the `FlowCanvas` hook:

```javascript
import { BrowserJsPlumbInstance, newInstance, EVENT_CONNECTION,
         EVENT_CONNECTION_DETACHED, AnchorLocations }
  from "@jsplumb/browser-ui"

const FlowCanvas = {
  mounted() {
    this.instance = newInstance({
      container: this.el,
      dragOptions: { cursor: "move" }
    })
    this.nodes  = {}
    this.setupEventListeners()
    // Server will push "init_canvas" once LiveView is connected
  },

  setupEventListeners() {
    // jsPlumb connection created by user
    this.instance.bind(EVENT_CONNECTION, (info) => {
      this.pushEvent("connection_made", {
        source_id: info.sourceId,
        target_id: info.targetId,
        source_anchor: info.sourceEndpoint.anchor.type,
        target_anchor: info.targetEndpoint.anchor.type
      })
    })

    this.instance.bind(EVENT_CONNECTION_DETACHED, (info) => {
      this.pushEvent("connection_removed", {
        source_id: info.sourceId,
        target_id: info.targetId
      })
    })
  },

  handleEvent(event, payload) {
    switch (event) {
      case "init_canvas":  return this.initCanvas(payload)
      case "node_ack":     return this.renderNode(payload)
      case "remove_node":  return this.removeNode(payload.id)
      case "connection_ack":      return this.styleConnection(payload)
      case "connection_rejected": return this.rejectConnection(payload)
      case "execution_overlay":   return this.applyExecutionOverlay(payload)
    }
  },

  destroyed() {
    this.instance.destroy()
  }
}

// Register hook
const hooks = { FlowCanvas }
const liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token }, hooks })
```

**Update `mix.exs` esbuild config** to resolve node_modules from assets directory.

**Deliverable:** `mix assets.deploy` includes jsPlumb bundle. Blank canvas mounts and
jsPlumb instance is active on `/admin/flow-builder/new`.

**Tests:**
- `assets/node_modules/@jsplumb/browser-ui` present after `npm install`
- esbuild compiles without errors
- Hook mounts without JS console errors
- jsPlumb instance accessible via `window.__flowCanvas` (dev mode only)

---

### Phase 8.1 — Node Palette, Canvas & Property Panel

**Goal:** Draggable node palette, canvas workspace, and right-side property panel for
editing node config. Nodes persist position and config in LiveView state.

#### Layout

```
┌──────────────┬─────────────────────────────────────┬──────────────────┐
│ Node Palette │  Canvas (jsPlumb workspace)           │ Property Panel   │
│              │                                       │                  │
│ ► Request    │  [drag nodes here]                   │ (click a node    │
│ ⬡ Slot       │                                       │  to edit its     │
│ ◈ Adapter    │                                       │  config)         │
│ ◇ Decision   │                                       │                  │
│ ↝ Transform  │                                       │ Node: Adapter    │
│ ⊕ Merge      │                                       │ Name: crm_api    │
│ ◎ Response   │                                       │ Timeout: 3000ms  │
│              │                                       │ Fallback on:     │
│              │  Toolbar:  [Apply] [Clone] [Clear]    │  ☑ timeout       │
│              │            [Zoom +] [Zoom -] [Fit]    │  ☑ error         │
│              │                                       │  ☑ empty         │
└──────────────┴─────────────────────────────────────┴──────────────────┘
```

#### Node HTML Template (rendered by LiveView, registered with jsPlumb)

Each node is a `<div>` with a `data-node-id` and `data-node-type` attribute. jsPlumb
adds anchors and manages connections. LiveView manages content.

```html
<!-- Rendered by FlowBuilderLive, one per node in assigns.nodes -->
<div id={"node-#{node.id}"}
     class={"flow-node flow-node--#{node.type}"}
     data-node-id={node.id}
     data-node-type={node.type}
     style={"left: #{node.x}px; top: #{node.y}px;"}>
  <div class="flow-node__header">
    <span class="flow-node__icon"><%= node_icon(node.type) %></span>
    <span class="flow-node__title"><%= node_title(node) %></span>
  </div>
  <div class="flow-node__body">
    <%= node_summary(node) %>
  </div>
</div>
```

**Node type styling (Tailwind):**

| Node Type | Border Color | Header Color |
|---|---|---|
| `request` | blue-500 | blue-100 |
| `slot` | purple-500 | purple-100 |
| `adapter` | green-500 | green-100 |
| `decision` | yellow-500 | yellow-100 |
| `transform` | orange-500 | orange-100 |
| `merge` | teal-500 | teal-100 |
| `response` | gray-500 | gray-100 |

#### `FlowBuilderLive` state model

```elixir
defmodule GatewayWeb.FlowBuilderLive do
  use GatewayWebWeb, :live_view

  @type node :: %{
    id:     String.t(),
    type:   :request | :slot | :adapter | :decision | :transform | :merge | :response,
    x:      integer(),
    y:      integer(),
    config: map()
  }

  @type connection :: %{
    id:          String.t(),
    source_id:   String.t(),
    target_id:   String.t(),
    type:        :primary | :fallback | :dependency | :yes_branch | :no_branch,
    source_anchor: String.t(),
    target_anchor: String.t()
  }

  @impl true
  def mount(%{"route_id" => route_id}, _session, socket) do
    {nodes, connections} = load_canvas_for_route(route_id)
    {:ok, assign(socket,
      route_id:       route_id,
      nodes:          nodes,
      connections:    connections,
      selected_node:  nil,
      validation_errors: []
    )}
  end
end
```

**Deliverable:** Nodes can be dragged from palette, dropped on canvas, selected to show
property panel, and repositioned. State is fully in LiveView assigns.

**Tests:**
- LiveView test: drag event adds node to `assigns.nodes`
- LiveView test: clicking node populates `assigns.selected_node`
- LiveView test: canvas renders all node types without error
- Property panel shows correct fields per node type

---

### Phase 8.2 — Connection Rules, Validation & Visual Styling

**Goal:** Enforce which nodes can connect to which, validate connection types, and style
connections visually (color, dash pattern, arrow style) per connection type.

#### Connection Rules

```
Source Node Type  ──────  Allowed Target Types     Allowed Connection Type
────────────────────────────────────────────────────────────────────────────
request           ──────  slot, transform           primary
slot              ──────  adapter                   primary
adapter           ──────  adapter (same slot)        fallback
adapter           ──────  merge, transform           primary (slot exit)
slot              ──────  merge                     primary (direct, no adapter)
transform         ──────  adapter, merge, response  primary
merge             ──────  transform, response       primary
decision          ──────  any                       yes_branch, no_branch
─── INVALID ────────────────────────────────────────────────────────────────
adapter           ──►  request                      (no backwards connections)
response          ──►  anything                     (sink — no outgoing)
two adapters      ──►  each other (same slot)       only if fallback + priority differs
```

#### jsPlumb Endpoint Configuration Per Node Type

```javascript
// Called when node is registered with jsPlumb
function configureAnchors(instance, nodeEl, nodeType) {
  const anchors = {
    request:   { sources: ["Bottom"],      targets: []       },
    slot:      { sources: ["Bottom"],      targets: ["Top"]  },
    adapter:   { sources: ["Bottom", "Right"],  targets: ["Top"]  },
    //           Bottom=primary exit    Right=fallback exit
    transform: { sources: ["Bottom"],      targets: ["Top"]  },
    merge:     { sources: ["Bottom"],      targets: ["Top", "Left", "Right"] },
    response:  { sources: [],             targets: ["Top"]  },
    decision:  { sources: ["Left","Right"], targets: ["Top"] }
    //           Left=no_branch  Right=yes_branch
  }
  // register endpoints on nodeEl...
}
```

#### Connection Visual Styles (jsPlumb paintStyle)

```javascript
const CONNECTION_STYLES = {
  primary:     { stroke: "#3B82F6", strokeWidth: 2, dashstyle: null },
  fallback:    { stroke: "#F97316", strokeWidth: 2, dashstyle: "4 3" },
  dependency:  { stroke: "#9CA3AF", strokeWidth: 1, dashstyle: "2 4" },
  yes_branch:  { stroke: "#22C55E", strokeWidth: 2, dashstyle: null },
  no_branch:   { stroke: "#EF4444", strokeWidth: 2, dashstyle: null }
}
```

#### Server-Side Graph Validation (LiveView)

When the user clicks "Apply", `FlowBuilderLive` validates the graph before saving:

```elixir
defmodule GatewayWeb.FlowBuilder.Validator do
  def validate(nodes, connections) do
    []
    |> check_single_request_node(nodes)
    |> check_single_response_node(nodes)
    |> check_no_orphan_nodes(nodes, connections)
    |> check_response_reachable(nodes, connections)
    |> check_at_least_one_required_slot(nodes)
    |> check_adapter_belongs_to_one_slot(nodes, connections)
    |> check_no_cycles(nodes, connections)           # except fallback edges
    |> check_fallback_priority_order(nodes, connections)
  end
end
```

**Deliverable:** Illegal connections are rejected with an error message. Valid connections
are styled correctly. Server-side validation catches logical errors on save.

**Tests:**
- Connection from `response` to any node → rejected, error shown
- Two adapters connected without fallback type → rejected
- Canvas with no required slot → validation error on Apply
- Orphan node (not connected to anything) → validation error
- Cycle detection: A → B → A rejected; A → B →(fallback)→ C allowed

---

### Phase 8.3 — Canvas ↔ CompositeRoute Serialization

**Goal:** Bidirectional conversion between the visual canvas (nodes + connections) and the
`MwKernel.CompositeRoute` struct that drives execution.

#### Canvas JSON Schema

Persisted in the route table entry alongside the execution spec:

```json
{
  "canvas_version": "1",
  "nodes": [
    {"id": "n1", "type": "request", "x": 400, "y": 50,
     "config": {"method": "GET", "path": "/api/v1/customer-view"}},
    {"id": "n2", "type": "slot", "x": 300, "y": 200,
     "config": {"name": "customer_profile", "required": false}},
    {"id": "n3", "type": "adapter", "x": 200, "y": 350,
     "config": {"adapter": "crm_api", "priority": 1, "timeout_ms": 3000,
                "fallback_on": ["timeout", "error", "empty"]}},
    {"id": "n4", "type": "adapter", "x": 400, "y": 350,
     "config": {"adapter": "local_db", "priority": 2, "timeout_ms": 1000,
                "fallback_on": []}},
    {"id": "n5", "type": "merge", "x": 400, "y": 500,
     "config": {"strategy": "deep_merge"}},
    {"id": "n6", "type": "response", "x": 400, "y": 650, "config": {}}
  ],
  "connections": [
    {"id": "c1", "source": "n1", "target": "n2", "type": "primary"},
    {"id": "c2", "source": "n2", "target": "n3", "type": "primary"},
    {"id": "c3", "source": "n3", "target": "n4", "type": "fallback",
     "config": {"triggers": ["timeout", "error", "empty"]}},
    {"id": "c4", "source": "n2", "target": "n5", "type": "primary"},
    {"id": "c5", "source": "n5", "target": "n6", "type": "primary"}
  ]
}
```

#### Serialization Module

```elixir
defmodule GatewayWeb.FlowBuilder.Serializer do
  @moduledoc """
  Converts between canvas JSON representation and MwKernel.CompositeRoute structs.
  Canvas JSON is stored for UI reconstruction. CompositeRoute is used for execution.
  """

  alias MwKernel.CompositeRoute
  alias MwKernel.CompositeRoute.{Slot, ChainEntry}

  @spec canvas_to_route(map()) :: {:ok, CompositeRoute.t()} | {:error, [String.t()]}
  def canvas_to_route(%{"nodes" => nodes, "connections" => conns}) do
    with {:ok, slots}    <- extract_slots(nodes, conns),
         {:ok, strategy} <- extract_merge_strategy(nodes) do
      {:ok, %CompositeRoute{slots: slots, merge_strategy: strategy}}
    end
  end

  @spec route_to_canvas(CompositeRoute.t(), map() | nil) :: map()
  def route_to_canvas(%CompositeRoute{} = route, existing_layout \\ nil) do
    # If existing_layout provided, preserve x/y positions
    # Otherwise auto-layout using a simple layered algorithm
    layout = existing_layout || auto_layout(route)
    build_canvas_json(route, layout)
  end

  # Auto-layout: request at top, slots in a row, adapters below each slot,
  # merge below all slots, response at bottom
  defp auto_layout(%CompositeRoute{slots: slots}) do
    slot_count  = length(slots)
    slot_x_step = 250
    base_x      = max(400, slot_count * slot_x_step / 2)

    %{
      request:  {round(base_x), 50},
      slots:    slots |> Enum.with_index() |> Map.new(fn {s, i} ->
                  {s.name, {100 + i * slot_x_step, 200}}
                end),
      adapters: %{},   # computed per slot below
      merge:    {round(base_x), 500},
      response: {round(base_x), 650}
    }
  end
end
```

**Route table persistence** — extend ETS entry to store `canvas_json`:

```elixir
# Route entry gains a canvas_json field (nil for single-adapter routes)
%{
  ...,
  composite_spec: %CompositeRoute{...},
  canvas_json:    %{...}    # raw canvas JSON for UI reconstruction
}
```

**Deliverable:** Apply button on canvas serializes → `CompositeRoute` → persisted.
Reopening the route loads from `canvas_json` and rebuilds the canvas with preserved
node positions.

**Tests:**
- `canvas_to_route/1` converts 2-slot canvas with fallback to correct `CompositeRoute`
- `route_to_canvas/2` converts `CompositeRoute` back to canvas JSON with all nodes/edges
- Round-trip: `route_to_canvas(canvas_to_route(json))` preserves all data
- Auto-layout produces non-overlapping positions for 1, 2, 3, 4 slot routes
- Existing `x/y` positions preserved when editing (not reset by `route_to_canvas`)

---

### Phase 8.4 — Execution Overlay (Live Request Tracing on Canvas)

**Goal:** When a composite request is processed, the flow canvas highlights each node
and connection in real time to show the execution path — which adapters were called,
which used fallback, and which failed.

This uses the existing OpenTelemetry span events and PubSub from Phase 7.5.

#### Flow

```
Request arrives → FanoutDispatcher emits telemetry events
                           │
                           ▼
                  InfraCache.PubSub broadcasts:
                  {:fanout_event, route_id, %{slot, adapter, result, duration}}
                           │
                           ▼
                  FlowBuilderLive subscribes (if viewing that route)
                  handle_info → updates assigns.execution_overlay
                           │
                           ▼
                  pushEvent("execution_overlay", %{node_id, status, duration_ms})
                           │
                           ▼
                  FlowCanvas JS Hook applies CSS classes to nodes:
                  .flow-node--active     (currently running — pulsing)
                  .flow-node--ok         (completed, green glow)
                  .flow-node--fallback   (fallback used, orange glow)
                  .flow-node--failed     (all failed, red glow)
```

#### Execution Overlay State

Each node gains an overlay badge showing:

```
┌──────────────────┐
│ ◈ crm_api        │  ← node normal state
│   priority: 1    │
└──────────────────┘

        ↓ during execution

┌──────────────────┐
│ ◈ crm_api        │  ← .flow-node--fallback (orange glow)
│   ⚡ 3001ms      │  ← duration badge
│   ↩ timeout      │  ← fallback reason badge
└──────────────────┘
```

**Deliverable:** Viewing a composite route's flow canvas during a live request shows
real-time execution progress. Post-request, the overlay persists until cleared or
next request.

**Tests:**
- PubSub event `{:fanout_event, ...}` triggers `handle_info` in LiveView
- `execution_overlay` assign updates correctly on each event
- `pushEvent("execution_overlay", ...)` fires for each adapter event
- Node with `outcome: :fallback_used` receives `.flow-node--fallback` class
- Overlay cleared on "Clear Overlay" button click

---

### Phase 8.5 — Route Integration, History & Polish

**Goal:** Integrate the flow builder into the admin navigation, link from route editor,
add undo/redo history, and polish the canvas UX.

#### New Admin Route

```elixir
# apps/gateway_web/lib/gateway_web_web/router.ex
live "/admin/flow-builder/new",         FlowBuilderLive, :new
live "/admin/flow-builder/:route_id",   FlowBuilderLive, :edit
```

**Navigation:**
- Sidebar entry: `Flow Builder` under `Platform Mgmt` group
- `RouteEditorLive` — composite routes show a `[Open in Flow Builder]` link
- Flow builder header shows breadcrumb: `Routes → /api/v1/customer-view → Flow Builder`

#### Undo / Redo

```elixir
# State in FlowBuilderLive assigns
%{
  nodes:       [...],
  connections: [...],
  history:     [prev_state_1, prev_state_2, ...],   # max 20 entries
  future:      []
}

# Ctrl+Z → pop from history, push current to future
# Ctrl+Y → pop from future, push current to history
```

Undo/redo is purely LiveView state — no DB interaction until Apply is clicked.

#### Canvas Toolbar

| Button | Action |
|---|---|
| Apply | Serialize canvas → save to route table |
| Clone Route | Duplicate this composite route with a new path |
| Clear Canvas | Reset to empty (with confirmation dialog) |
| Clear Overlay | Remove execution overlay colours |
| Fit to Screen | jsPlumb `repaintEverything()` + reset zoom |
| Export JSON | Download canvas JSON as `.json` file |
| Import JSON | Upload canvas JSON to restore a saved canvas |
| Zoom + / – | Scale canvas view |

#### Mini-map

For large flows (5+ slots), render a mini-map in the bottom-right corner:
- 150×100px overview of the entire canvas
- Highlighted viewport indicator (shows current view area)
- Click on mini-map to pan canvas

**Deliverable:** Flow builder is a first-class admin page. Undo/redo works for up to
20 steps. Import/export enables canvas sharing between environments (dev → staging).

**Tests:**
- Undo after adding 3 nodes restores to 2 nodes
- Redo after undo restores the 3-node state
- Export JSON downloads valid canvas JSON
- Import JSON from file restores canvas with all nodes and connections
- Clone Route creates a new route entry with copied `canvas_json` and `composite_spec`

---

## File Map

### New Files

```
apps/gateway_web/assets/package.json                    — jsPlumb npm dep
apps/gateway_web/assets/js/hooks/flow_canvas.js         — FlowCanvas LiveView hook
apps/gateway_web/assets/js/hooks/index.js               — hook registry
apps/gateway_web/assets/css/flow_builder.css            — node + connection styles
apps/gateway_web/lib/gateway_web_web/live/
  flow_builder_live.ex                                   — main LiveView
  flow_builder_live.html.heex                           — canvas template
apps/gateway_web/lib/gateway_web_web/flow_builder/
  validator.ex                                           — graph validation rules
  serializer.ex                                          — canvas ↔ CompositeRoute
  auto_layout.ex                                         — auto position algorithm
```

### Modified Files

```
apps/gateway_web/assets/js/app.js                       — register hooks, import jsPlumb
apps/gateway_web/lib/gateway_web_web/router.ex          — add flow_builder routes
apps/gateway_web/lib/gateway_web_web/layouts.ex         — add sidebar nav entry
apps/gateway_web/lib/gateway_web_web/live/
  route_editor_live.ex                                   — add "Open in Flow Builder" link
apps/mw_router/lib/mw_router/route_table.ex             — add canvas_json field to entry
apps/infra_telemetry/lib/infra_telemetry/metrics.ex     — broadcast fanout events to PubSub
```

### Test Files

```
apps/gateway_web/test/flow_builder/validator_test.exs
apps/gateway_web/test/flow_builder/serializer_test.exs
apps/gateway_web/test/live/flow_builder_live_test.exs
```

---

## Dependencies

| Capability | What | How |
|---|---|---|
| Visual canvas | `@jsplumb/browser-ui ^6.0.0` | `assets/package.json` (npm) |
| JS bundling | esbuild (already in Phoenix 1.7) | extend config to resolve `node_modules` |
| Real-time updates | Phoenix PubSub (already present) | subscribe in `FlowBuilderLive.mount/2` |
| Node styling | Tailwind CSS (already present) | add flow-node classes to `app.css` |

No new Elixir dependencies required.

---

## Phase Ordering Rationale

```
Phase 7.0–7.3  Backend (Slot, Chain, FanoutDispatcher, Merger)
       │
       ▼
Phase 7.4      Form-based editor — validates backend, ships faster
       │
       ▼
Phase 7.5–7.6  Observability + integration tests
       │
       ▼
Phase 8.0–8.3  Visual canvas — replaces 7.4 as primary design surface
       │
       ▼
Phase 8.4      Execution overlay — requires 7.5 telemetry events
       │
       ▼
Phase 8.5      Polish + history + integration
       │
       ▼
Phase 9        DAG/Conditional execution — builds on canvas designed in Phase 8
```

---

## Definition of Done

- [ ] jsPlumb v6 bundled via esbuild, no console errors
- [ ] All 7 node types render on canvas with correct styling
- [ ] All 5 connection types visually distinct (color + dash pattern)
- [ ] Connection rule validation rejects all illegal edge types
- [ ] Apply button serializes canvas → valid `CompositeRoute` and persists
- [ ] Load existing composite route → canvas renders with preserved positions
- [ ] Round-trip test: `canvas_to_route → route_to_canvas` lossless
- [ ] Execution overlay highlights nodes during live test request
- [ ] Undo/redo works for 20 steps without state corruption
- [ ] Import/export JSON works across browser sessions
- [ ] `mix credo --strict` clean on all new Elixir modules
- [ ] LiveView tests cover all `handle_event` paths in `FlowBuilderLive`

---

## Open Questions

| # | Question | Owner | Resolution |
|---|----------|-------|------------|
| 1 | Should the flow builder replace Phase 7.4's form editor, or coexist? | Product | Coexist initially — form editor for simple routes, flow builder for composite. Merge in Phase 8.5. |
| 2 | Should Decision nodes (Phase 9 feature) be available in the palette in Phase 8 but non-executable? | Engineering | Yes — render as "coming soon" / greyed out in Phase 8, activated in Phase 9. |
| 3 | Canvas storage: ETS-only or DB-persisted? | Engineering | DB-persisted (canvas_json column on routes table) — canvas must survive restarts. |
| 4 | Should execution overlay support historical requests (replay), or only live? | Product | Phase 8: live only. Historical replay via audit log in Phase 9. |
| 5 | Auto-layout algorithm: simple layered (top-down) or force-directed? | Engineering | Layered for Phase 8 (predictable). Force-directed available as toggle in Phase 9. |
