Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
- Start backend:
cd backend && uvicorn app.main:app --reload - Start frontend:
cd frontend && npm run dev - Open tree editor with an existing tree
- Edit an action node → verify "Next Step" dropdown appears with all nodes listed
- Select a node from a different branch → verify dashed purple arrow appears on canvas
- Edit a decision node → expand "Link an option to an existing node" → create a cross-reference
- Verify circular reference warning (not error) appears in validation panel
- 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) |