Files
resolutionflow/docs/plans/2026-02-28-cross-reference-loopback.md
2026-02-28 20:43:34 -05:00

25 KiB

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:

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):

                    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:

                    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:

    # 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:

    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

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:

                errors.push({
                  nodeId: node.id,
                  message: `Circular reference detected: "${opt.label}" creates a loop`,
                  severity: 'error'
                })

With:

                errors.push({
                  nodeId: node.id,
                  message: `This path loops back to an earlier node via "${opt.label}"`,
                  severity: 'warning'
                })

Replace lines 815-819:

            errors.push({
              nodeId: node.id,
              message: `Circular reference detected in node "${node.title || node.id}"`,
              severity: 'error'
            })

With:

            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

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):

/** 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:

    // 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

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:

/** 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:

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):

      {/* 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

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:

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:

                  {/* 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:

        {/* 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

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:

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:

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

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:

    # 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

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)