# Cross-Reference / Loop-Back Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Enable tree nodes to reference any other node in the tree (not just direct children), supporting loop-back patterns like "remediate → re-verify from earlier checkpoint."

**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed overlay arrows. Navigation already supports this pattern.

**Tech Stack:** Python FastAPI (backend validation), React + @xyflow/react (canvas rendering), Zustand (store validation), TypeScript

**Design Doc:** `docs/plans/2026-02-28-cross-reference-loopback-design.md`

---

## Task 1: Backend — Relax Decision Option Validation

Allow decision option `next_node_id` to reference ANY node in the tree, not just direct children.

**Files:**
- Modify: `backend/app/core/ai_tree_validator.py:111-118`
- Test: `backend/tests/test_ai_tree_validator.py`

**Step 1: Write the failing test — cross-reference option passes validation**

Add a new test class `TestCrossReferenceSupport` at the bottom of `test_ai_tree_validator.py`:

```python
class TestCrossReferenceSupport:
    def test_option_referencing_non_child_node_in_tree_is_valid(self):
        """A decision option can reference any node in the tree, not just direct children."""
        tree = _make_valid_tree()
        # Make root option point to a grandchild (not a direct child) — cross-reference
        tree["options"][0]["next_node_id"] = "fix-errors"  # grandchild of root
        errors = validate_generated_tree(tree)
        # Should NOT have the "non-existent child" error for this reference
        assert not any("non-existent child" in e for e in errors)

    def test_option_referencing_nonexistent_node_still_fails(self):
        """Cross-references must still point to nodes that exist in the tree."""
        tree = _make_valid_tree()
        tree["options"][0]["next_node_id"] = "totally-fake-id"
        errors = validate_generated_tree(tree)
        assert any("does not exist" in e for e in errors)

    def test_action_next_node_id_to_ancestor_is_valid(self):
        """Action node can loop back to an ancestor node (the whole point of cross-refs)."""
        tree = _make_valid_tree()
        # Make the action node loop back to root
        tree["children"][1]["next_node_id"] = "root"
        errors = validate_generated_tree(tree)
        assert not any("does not exist" in e for e in errors)
```

**Step 2: Run the tests to verify they fail**

Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py::TestCrossReferenceSupport -v`
Expected: `test_option_referencing_non_child_node_in_tree_is_valid` FAILS (currently raises "non-existent child" error). The other two should already pass.

**Step 3: Update validator — check global existence, not just children**

In `backend/app/core/ai_tree_validator.py`, replace lines 111-118 (the decision option next_node_id check):

Old code (lines 111-118):
```python
                    next_id = opt.get("next_node_id")
                    if next_id:
                        all_referenced_ids.add(next_id)
                        if child_ids and next_id not in child_ids:
                            errors.append(
                                f"Option '{opt.get('label', '?')}' in node '{node_id}' "
                                f"references non-existent child '{next_id}'"
                            )
```

New code:
```python
                    next_id = opt.get("next_node_id")
                    if next_id:
                        all_referenced_ids.add(next_id)
```

Then add a new global check after line 145 (after the action next_node_id existence check). This checks ALL option references exist anywhere in the tree:

After the existing `for ref_id in action_next_ids:` block, add:
```python
    # Check that all option next_node_ids exist in the tree (allows cross-references)
    for ref_id in all_referenced_ids:
        if ref_id not in all_ids:
            errors.append(
                f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
            )
```

**Step 4: Run all validator tests to verify they pass**

Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL tests pass. The old `test_option_references_nonexistent_child` test in `TestReferenceIntegrity` will now fail because the error message changed from "non-existent child" to "does not exist in the tree". Update that test:

In `TestReferenceIntegrity.test_option_references_nonexistent_child`, change:
```python
    def test_option_references_nonexistent_child(self):
        tree = _make_valid_tree()
        tree["options"][0]["next_node_id"] = "nonexistent"
        errors = validate_generated_tree(tree)
        assert any("does not exist" in e for e in errors)
```

Run again: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add backend/app/core/ai_tree_validator.py backend/tests/test_ai_tree_validator.py
git commit -m "feat: relax decision option validation — allow cross-references to any node in tree"
```

---

## Task 2: Frontend — Change Circular Reference Detection From Error to Warning

Loop-backs are now intentional. The circular reference detector should warn instead of error.

**Files:**
- Modify: `frontend/src/store/treeEditorStore.ts:791-824`

**Step 1: Update severity from 'error' to 'warning' and improve messages**

In `frontend/src/store/treeEditorStore.ts`, find the `detectCircularRefs` function (lines 792-824). Change both `severity: 'error'` to `severity: 'warning'` and update messages:

Replace line 803-807:
```typescript
                errors.push({
                  nodeId: node.id,
                  message: `Circular reference detected: "${opt.label}" creates a loop`,
                  severity: 'error'
                })
```
With:
```typescript
                errors.push({
                  nodeId: node.id,
                  message: `This path loops back to an earlier node via "${opt.label}"`,
                  severity: 'warning'
                })
```

Replace lines 815-819:
```typescript
            errors.push({
              nodeId: node.id,
              message: `Circular reference detected in node "${node.title || node.id}"`,
              severity: 'error'
            })
```
With:
```typescript
            errors.push({
              nodeId: node.id,
              message: `This node loops back to an earlier node ("${node.title || node.id}")`,
              severity: 'warning'
            })
```

**Step 2: Build to verify**

Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.

**Step 3: Commit**

```bash
git add frontend/src/store/treeEditorStore.ts
git commit -m "feat: change circular reference detection from error to warning — loops are intentional"
```

---

## Task 3: Canvas — Render Cross-Reference Edges as Dashed Arrows

Add dashed purple overlay edges for any `next_node_id` pointing outside the current node's children.

**Files:**
- Modify: `frontend/src/components/tree-editor/useTreeLayout.ts`

**Step 1: Add cross-reference edge collection to the `walk` function**

In `useTreeLayout.ts`, inside the `useMemo` callback (line 57), after the `walk(treeStructure, null)` call on line 129, add a second pass to collect cross-reference edges.

Add this helper function before the `useTreeLayout` export (around line 40):

```typescript
/** Collect all node IDs in the tree. */
function collectAllIds(root: TreeStructure): Set<string> {
  const ids = new Set<string>()
  function walk(node: TreeStructure) {
    ids.add(node.id)
    node.children?.forEach(walk)
  }
  walk(root)
  return ids
}

/** Find all cross-reference edges (next_node_id pointing outside children). */
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
  const refs: Array<{ source: string; target: string; label?: string }> = []
  const allIds = collectAllIds(root)

  function walk(node: TreeStructure) {
    const childIds = new Set(node.children?.map(c => c.id) ?? [])

    // Decision options pointing outside children
    if (node.type === 'decision' && node.options) {
      for (const opt of node.options) {
        if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
          refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
        }
      }
    }

    // Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
    if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
      refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
    }

    node.children?.forEach(walk)
  }

  walk(root)
  return refs
}
```

**Step 2: Add cross-reference edges to the edges array**

In the `useMemo` callback, after `walk(treeStructure, null)` (line 129) and before the return (line 131), add:

```typescript
    // Add cross-reference edges (dashed, purple)
    if (treeStructure) {
      const crossRefs = collectCrossRefEdges(treeStructure)
      for (const ref of crossRefs) {
        // Only add if both source and target nodes are visible (not collapsed away)
        const sourceVisible = nodes.some(n => n.id === ref.source)
        const targetVisible = nodes.some(n => n.id === ref.target)
        if (sourceVisible && targetVisible) {
          edges.push({
            id: `xref-${ref.source}->${ref.target}`,
            source: ref.source,
            target: ref.target,
            type: 'smoothstep',
            animated: true,
            label: ref.label ? truncateLabel(ref.label) : undefined,
            labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
            labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
            labelBgPadding: [4, 2] as [number, number],
            style: {
              stroke: 'hsl(var(--primary))',
              strokeWidth: 2,
              strokeDasharray: '6 3',
            },
            markerEnd: {
              type: 'arrowclosed' as const,
              color: 'hsl(var(--primary))',
              width: 16,
              height: 16,
            },
          })
        }
      }
    }
```

**Step 3: Build to verify**

Run: `cd frontend && npm run build`
Expected: Build succeeds. Cross-reference edges render as animated, dashed purple arrows with arrowheads.

**Step 4: Commit**

```bash
git add frontend/src/components/tree-editor/useTreeLayout.ts
git commit -m "feat: render cross-reference edges as dashed purple arrows on canvas"
```

---

## Task 4: Editor UX — Node Picker Dropdown for Action Nodes

Add a "Link to existing node" dropdown to `NodeFormAction.tsx`.

**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
- Modify: `frontend/src/store/treeEditorStore.ts` (add helper to collect all nodes)

**Step 1: Add `collectAllNodes` helper to the tree editor store**

In `frontend/src/store/treeEditorStore.ts`, add a standalone exported helper function (near the top of the file, after imports, or as a utility):

Find the `findNodeInTree` helper function. Near it, add:

```typescript
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
  root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
  if (!root) return []
  const result: Array<{ id: string; label: string; type: string; depth: number }> = []

  function walk(node: TreeStructure, depth: number) {
    const label = node.type === 'decision'
      ? (node.question || 'Untitled Decision')
      : (node.title || `Untitled ${node.type}`)
    result.push({ id: node.id, label, type: node.type, depth })
    node.children?.forEach(child => walk(child, depth + 1))
  }

  walk(root, 0)
  return result
}
```

**Step 2: Update NodeFormAction to include the node picker**

Replace the "Next step hint" section at the bottom of `NodeFormAction.tsx` (lines 161-170) with a full node picker:

```tsx
import { Link2, X } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```

(Add these to existing imports at top of file)

Replace lines 161-170 (the `{hasNextNode ? ... : ...}` block):

```tsx
      {/* Link to existing node */}
      <div>
        <label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
          <Link2 className="h-3.5 w-3.5" />
          Next Step
        </label>
        {hasNextNode ? (
          <div className="mt-1 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
            <span className="flex-1 truncate text-sm text-foreground">
              Linked to: {(() => {
                const treeStructure = useTreeEditorStore.getState().treeStructure
                const allNodes = collectAllNodesFlat(treeStructure)
                const target = allNodes.find(n => n.id === node.next_node_id)
                return target ? target.label : node.next_node_id
              })()}
            </span>
            <button
              type="button"
              onClick={() => onUpdate({ next_node_id: undefined })}
              className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
              title="Remove link"
            >
              <X className="h-3.5 w-3.5" />
            </button>
          </div>
        ) : (
          <select
            value=""
            onChange={(e) => {
              if (e.target.value) {
                onUpdate({ next_node_id: e.target.value })
              }
            }}
            className={cn(
              'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
              'bg-card text-foreground',
              'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
            )}
          >
            <option value="">Link to existing node...</option>
            {(() => {
              const treeStructure = useTreeEditorStore.getState().treeStructure
              const allNodes = collectAllNodesFlat(treeStructure)
              return allNodes
                .filter(n => n.id !== node.id && n.type !== 'answer')
                .map(n => (
                  <option key={n.id} value={n.id}>
                    {'  '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
                  </option>
                ))
            })()}
          </select>
        )}
        <p className="mt-1 text-xs text-muted-foreground">
          {hasNextNode
            ? 'This action will navigate to the linked node.'
            : 'Select a node to navigate to after this action, or save to create a new placeholder.'}
        </p>
      </div>
```

**Step 3: Build to verify**

Run: `cd frontend && npm run build`
Expected: Build succeeds.

**Step 4: Commit**

```bash
git add frontend/src/components/tree-editor/NodeFormAction.tsx frontend/src/store/treeEditorStore.ts
git commit -m "feat: add node picker dropdown to action node form for cross-references"
```

---

## Task 5: Editor UX — Node Picker for Decision Option Rows

Add "Link to existing node" capability to each decision option row.

**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`

**Step 1: Add link icon and dropdown per option row**

Add imports at top of `NodeFormDecision.tsx`:
```tsx
import { Link2 } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```

In the option render callback (inside `renderItem` around line 161), after the label input and its error message, add a cross-reference link indicator per option. After the closing `</div>` of the `flex-1` wrapper (around line 197), add:

```tsx
                  {/* Cross-reference link indicator */}
                  {option.next_node_id && (() => {
                    const treeStructure = useTreeEditorStore.getState().treeStructure
                    const childIds = new Set(node.children?.map(c => c.id) ?? [])
                    // Only show if it's a cross-reference (points outside children)
                    if (childIds.has(option.next_node_id)) return null
                    const allNodes = collectAllNodesFlat(treeStructure)
                    const target = allNodes.find(n => n.id === option.next_node_id)
                    if (!target) return null
                    return (
                      <div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
                        <Link2 className="h-3 w-3" />
                      </div>
                    )
                  })()}
```

**Step 2: Add "Link to node" option below the options list**

After the `DynamicArrayField` closing tag (line 201, before the root tip), add:

```tsx
        {/* Quick-link: assign an option to an existing node */}
        <details className="mt-2">
          <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
            <Link2 className="inline h-3 w-3 mr-1" />
            Link an option to an existing node (cross-reference)
          </summary>
          <div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
            <p className="text-xs text-muted-foreground">
              Select an option, then pick a target node. This creates a loop-back or cross-reference.
            </p>
            <div className="flex gap-2">
              <select
                id="xref-option-select"
                className={cn(
                  'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
                  'bg-card text-foreground'
                )}
                defaultValue=""
              >
                <option value="">Select option...</option>
                {(node.options || []).map((opt, i) => (
                  <option key={opt.id} value={i}>
                    {indexToLetter(i)}: {opt.label || '(empty)'}
                  </option>
                ))}
              </select>
              <select
                id="xref-target-select"
                className={cn(
                  'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
                  'bg-card text-foreground'
                )}
                defaultValue=""
                onChange={(e) => {
                  const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
                  const optIndex = parseInt(optSelect?.value, 10)
                  const targetId = e.target.value
                  if (!isNaN(optIndex) && targetId) {
                    handleUpdateOption(optIndex, { next_node_id: targetId })
                    // Reset selects
                    optSelect.value = ''
                    e.target.value = ''
                  }
                }}
              >
                <option value="">Select target node...</option>
                {(() => {
                  const treeStructure = useTreeEditorStore.getState().treeStructure
                  const allNodes = collectAllNodesFlat(treeStructure)
                  return allNodes
                    .filter(n => n.id !== node.id && n.type !== 'answer')
                    .map(n => (
                      <option key={n.id} value={n.id}>
                        {'  '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
                      </option>
                    ))
                })()}
              </select>
            </div>
          </div>
        </details>
```

**Step 2: Build to verify**

Run: `cd frontend && npm run build`
Expected: Build succeeds.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "feat: add cross-reference node picker to decision option rows"
```

---

## Task 6: AI System Prompt — Update Structural Rules for Cross-References

Update the AI chat system prompt to allow and encourage loop-back patterns.

**Files:**
- Modify: `backend/app/core/ai_chat_service.py:68-75`

**Step 1: Update STRUCTURAL RULES in SCHEMA_CONTEXT**

Replace the STRUCTURAL RULES section (lines 68-75 of `ai_chat_service.py`):

Old:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id must reference a child node's id
- Action nodes use next_node_id to chain to the next step (NOT children)
- Solution nodes are terminal — no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
```

New:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id typically references a child node's id, BUT can also reference ANY other node in the tree for loop-back / re-verification patterns
- Action nodes use next_node_id to chain to the next step — this can point to any node in the tree, including ancestors, for loop-backs (e.g., "remediate → re-verify from earlier checkpoint")
- Solution nodes are terminal — no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")

CROSS-REFERENCE / LOOP-BACK PATTERN:
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
```

**Step 2: Build backend to verify syntax**

Run: `cd backend && python -c "from app.core.ai_chat_service import SCHEMA_CONTEXT; print('OK')"`
Expected: Prints "OK" with no import errors.

**Step 3: Commit**

```bash
git add backend/app/core/ai_chat_service.py
git commit -m "feat: update AI system prompt to allow cross-reference loop-back patterns"
```

---

## Task 7: Backend — Update Option Validation Error for `all_referenced_ids`

The `all_referenced_ids` set currently holds only option `next_node_id` values. After Task 1's change, the global existence check also needs to handle the case where `action_next_ids` and `all_referenced_ids` may overlap.

**Files:**
- Modify: `backend/app/core/ai_tree_validator.py`
- Test: `backend/tests/test_ai_tree_validator.py`

**Step 1: Verify no double-counting between action and option refs**

Check: `action_next_ids` are added to `all_referenced_ids` on line 128. After Task 1, we added a global check for `all_referenced_ids`. This means action refs get checked twice — once in the action-specific loop (lines 141-145) and once in the new option loop. We should only check option refs in the new loop.

Update the global check added in Task 1 to exclude action refs:

```python
    # Check that all option next_node_ids exist in the tree (allows cross-references)
    for ref_id in all_referenced_ids - action_next_ids:
        if ref_id not in all_ids:
            errors.append(
                f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
            )
```

**Step 2: Run all validator tests**

Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS

**Step 3: Commit**

```bash
git add backend/app/core/ai_tree_validator.py
git commit -m "fix: prevent double-counting action refs in global option cross-reference check"
```

---

## Task 8: Full Integration Test

Run the full backend test suite and frontend build to verify nothing is broken.

**Files:** (none — testing only)

**Step 1: Run backend tests**

Run: `cd backend && python -m pytest --override-ini="addopts=" -v`
Expected: ALL PASS

**Step 2: Run frontend build**

Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.

**Step 3: Manual smoke test**

1. Start backend: `cd backend && uvicorn app.main:app --reload`
2. Start frontend: `cd frontend && npm run dev`
3. Open tree editor with an existing tree
4. Edit an action node → verify "Next Step" dropdown appears with all nodes listed
5. Select a node from a different branch → verify dashed purple arrow appears on canvas
6. Edit a decision node → expand "Link an option to an existing node" → create a cross-reference
7. Verify circular reference warning (not error) appears in validation panel
8. Navigate the tree → verify loop-back works (session follows `next_node_id`)

---

## Summary

| Task | What | Files |
|------|------|-------|
| 1 | Backend: relax option validation | `ai_tree_validator.py`, `test_ai_tree_validator.py` |
| 2 | Frontend: circular ref → warning | `treeEditorStore.ts` |
| 3 | Canvas: dashed purple cross-ref edges | `useTreeLayout.ts` |
| 4 | Editor: action node picker | `NodeFormAction.tsx`, `treeEditorStore.ts` |
| 5 | Editor: decision option picker | `NodeFormDecision.tsx` |
| 6 | AI prompt: loop-back awareness | `ai_chat_service.py` |
| 7 | Backend: fix ref overlap check | `ai_tree_validator.py` |
| 8 | Integration test | (testing only) |
