Skip to main content
The Decision Graph API provides a persistent, queryable graph of AI decisions, goals, and reasoning. It enables tracking the evolution of architectural decisions, linking related choices, and understanding why the AI made specific recommendations.

Overview

The Loom.Decisions.Graph module maintains a graph database of:
  • Nodes: Decisions, options, goals, observations, and assumptions
  • Edges: Relationships like supports, conflicts, supersedes, and depends_on
This creates an auditable trail of AI reasoning that can be queried, visualized, and used to inform future decisions.

Nodes

Node Types

Decision nodes can be one of:
  • :decision: A concrete choice made by the AI
  • :option: A potential alternative considered
  • :goal: A desired outcome or objective
  • :observation: A fact or constraint discovered during analysis
  • :assumption: A working assumption made during reasoning

Node Status

  • :active: Currently in effect
  • :superseded: Replaced by a newer decision
  • :rejected: Considered but not chosen

add_node/1

Create a new decision node.
{:ok, node} = Loom.Decisions.Graph.add_node(%{
  node_type: :decision,
  title: "Use Phoenix LiveView for UI",
  description: "Adopt LiveView for real-time UI updates without custom JS",
  status: :active,
  session_id: "session-123",
  tags: ["architecture", "frontend"],
  metadata: %{
    considered_at: DateTime.utc_now(),
    impact: "high"
  }
})
node_type
atom()
required
One of: :decision, :option, :goal, :observation, :assumption
title
String.t()
required
Short, descriptive title for the node.
description
String.t()
Detailed explanation of the decision or observation.
status
atom()
default:":active"
Node status: :active, :superseded, or :rejected
session_id
String.t()
Associate this node with a specific session.
tags
[String.t()]
default:"[]"
Categorization tags for filtering and search.
metadata
map()
default:"%{}"
Arbitrary JSON-serializable metadata.
change_id
String.t()
UUID to group related changes. Auto-generated if not provided.
{:ok, node}
{:ok, %DecisionNode{}}
The created node struct with assigned ID and timestamps.
{:error, changeset}
{:error, Ecto.Changeset.t()}
Validation errors if node creation failed.

get_node/1

Retrieve a node by ID.
node = Loom.Decisions.Graph.get_node("550e8400-e29b-41d4-a716-446655440000")
node
%DecisionNode{} | nil
The node struct or nil if not found.

get_node!/1

Retrieve a node by ID, raising if not found.
node = Loom.Decisions.Graph.get_node!("550e8400-e29b-41d4-a716-446655440000")
# Raises Ecto.NoResultsError if not found

update_node/2

Update an existing node.
# Using a node struct
node = Loom.Decisions.Graph.get_node!(node_id)
{:ok, updated} = Loom.Decisions.Graph.update_node(node, %{
  status: :superseded,
  description: "Updated reasoning"
})

# Using just the ID
{:ok, updated} = Loom.Decisions.Graph.update_node(node_id, %{
  tags: ["architecture", "deprecated"]
})
node
%DecisionNode{} | String.t()
Either the node struct or node ID.
attrs
map()
Map of attributes to update.
{:ok, node}
{:ok, %DecisionNode{}}
The updated node.
{:error, reason}
{:error, term()}
Error details (:not_found or validation errors).

delete_node/1

Delete a node by ID.
{:ok, deleted_node} = Loom.Decisions.Graph.delete_node(node_id)
Deleting a node does not cascade-delete associated edges. Orphaned edges may remain in the graph.

list_nodes/1

Query nodes with optional filters.
# All active decisions
decisions = Loom.Decisions.Graph.list_nodes(
  node_type: :decision,
  status: :active
)

# All nodes for a session
session_nodes = Loom.Decisions.Graph.list_nodes(
  session_id: "session-123"
)

# All nodes
all_nodes = Loom.Decisions.Graph.list_nodes()
filters
keyword()
default:"[]"
Filter criteria:
  • node_type: atom() - Filter by node type
  • status: atom() - Filter by status
  • session_id: String.t() - Filter by session
nodes
[%DecisionNode{}]
List of matching nodes.

Edges

Edges represent relationships between nodes in the decision graph.

Edge Types

  • :supports: This decision supports another
  • :conflicts: This decision conflicts with another
  • :supersedes: This decision replaces an older one
  • :depends_on: This decision depends on another
  • :implements: This decision implements a goal
  • :considers: This decision considered an option

add_edge/4

Create a relationship between two nodes.
{:ok, edge} = Loom.Decisions.Graph.add_edge(
  decision_node_id,
  goal_node_id,
  :implements,
  rationale: "This decision directly implements the stated goal",
  weight: 0.9
)
from_id
String.t()
required
Source node ID.
to_id
String.t()
required
Target node ID.
edge_type
atom()
required
Relationship type: :supports, :conflicts, :supersedes, :depends_on, :implements, :considers
opts
keyword()
default:"[]"
Optional parameters:
  • rationale: String.t() - Explanation for this relationship
  • weight: float() - Relationship strength (0.0 to 1.0)
{:ok, edge}
{:ok, %DecisionEdge{}}
The created edge struct.

list_edges/1

Query edges with optional filters.
# All edges of a specific type
supports = Loom.Decisions.Graph.list_edges(edge_type: :supports)

# All edges from a node
outgoing = Loom.Decisions.Graph.list_edges(from_node_id: node_id)

# All edges to a node
incoming = Loom.Decisions.Graph.list_edges(to_node_id: node_id)
filters
keyword()
default:"[]"
Filter criteria:
  • edge_type: atom() - Filter by relationship type
  • from_node_id: String.t() - Filter by source node
  • to_node_id: String.t() - Filter by target node
edges
[%DecisionEdge{}]
List of matching edges.

Convenience Functions

active_goals/0

Get all active goal nodes.
goals = Loom.Decisions.Graph.active_goals()

Enum.each(goals, fn goal ->
  IO.puts("Goal: #{goal.title}")
end)
goals
[%DecisionNode{}]
All nodes with node_type: :goal and status: :active.

recent_decisions/1

Get the most recent decisions.
recent = Loom.Decisions.Graph.recent_decisions(20)

Enum.each(recent, fn decision ->
  IO.puts("[#{decision.inserted_at}] #{decision.title}")
end)
limit
integer()
default:"10"
Maximum number of decisions to return.
decisions
[%DecisionNode{}]
Recent decisions and options, ordered by insertion time (newest first).

supersede/3

Mark an old decision as superseded by a new one.
{:ok, edge} = Loom.Decisions.Graph.supersede(
  old_decision_id,
  new_decision_id,
  "New approach uses async processing for better performance"
)
This function:
  1. Creates a :supersedes edge from new to old
  2. Updates the old node’s status to :superseded
  3. Runs both operations in a database transaction
old_node_id
String.t()
ID of the decision being superseded.
new_node_id
String.t()
ID of the decision that replaces it.
rationale
String.t()
Explanation of why the decision was superseded.
{:ok, edge}
{:ok, %DecisionEdge{}}
The created supersedes edge.
{:error, reason}
{:error, term()}
Error from either edge creation or node update.

Complete Example

alias Loom.Decisions.Graph

# Create a goal
{:ok, goal} = Graph.add_node(%{
  node_type: :goal,
  title: "Improve API response time",
  description: "Reduce p95 latency from 500ms to 200ms",
  status: :active,
  session_id: "perf-session-1"
})

# Create multiple options
{:ok, option1} = Graph.add_node(%{
  node_type: :option,
  title: "Add Redis cache",
  description: "Cache frequent DB queries in Redis",
  metadata: %{estimated_impact: "30% improvement"}
})

{:ok, option2} = Graph.add_node(%{
  node_type: :option,
  title: "Database query optimization",
  description: "Add indexes and optimize N+1 queries",
  metadata: %{estimated_impact: "50% improvement"}
})

# Link options to goal
Graph.add_edge(option1.id, goal.id, :implements,
  rationale: "Caching reduces database load"
)

Graph.add_edge(option2.id, goal.id, :implements,
  rationale: "Query optimization directly addresses root cause"
)

# Make a decision
{:ok, decision} = Graph.add_node(%{
  node_type: :decision,
  title: "Use database query optimization",
  description: "Implement query optimization as primary solution",
  status: :active,
  session_id: "perf-session-1"
})

# Link decision to chosen option
Graph.add_edge(decision.id, option2.id, :implements,
  rationale: "Best long-term solution with highest impact",
  weight: 1.0
)

# Mark other option as rejected
Graph.update_node(option1, %{status: :rejected})

# Add an observation
{:ok, obs} = Graph.add_node(%{
  node_type: :observation,
  title: "Most queries missing indexes",
  description: "70% of slow queries lack appropriate indexes",
  session_id: "perf-session-1"
})

# Link observation to decision
Graph.add_edge(decision.id, obs.id, :depends_on,
  rationale: "This observation informed the decision"
)

# Later: supersede with improved approach
{:ok, new_decision} = Graph.add_node(%{
  node_type: :decision,
  title: "Combine query optimization with caching",
  description: "Use both approaches for maximum impact",
  status: :active,
  session_id: "perf-session-2"
})

Graph.supersede(
  decision.id,
  new_decision.id,
  "Testing showed combined approach yields 60% improvement"
)

# Query the graph
active_decisions = Graph.list_nodes(node_type: :decision, status: :active)
all_goals = Graph.active_goals()
recent = Graph.recent_decisions(10)

# Find all decisions that implement a goal
implementations = Graph.list_edges(
  to_node_id: goal.id,
  edge_type: :implements
)

Enum.each(implementations, fn edge ->
  decision = Graph.get_node(edge.from_node_id)
  IO.puts("Decision: #{decision.title}")
  IO.puts("Rationale: #{edge.rationale}")
end)

Schema Reference

DecisionNode Schema

%DecisionNode{
  id: String.t(),                    # UUID
  node_type: atom(),                 # :decision | :option | :goal | :observation | :assumption
  title: String.t(),                 # Short description
  description: String.t() | nil,     # Detailed explanation
  status: atom(),                    # :active | :superseded | :rejected
  session_id: String.t() | nil,      # Associated session
  tags: [String.t()],                # Categorization tags
  metadata: map(),                   # Arbitrary JSON data
  change_id: String.t(),             # Groups related changes
  inserted_at: DateTime.t(),         # Creation timestamp
  updated_at: DateTime.t()           # Last update timestamp
}

DecisionEdge Schema

%DecisionEdge{
  id: String.t(),                    # UUID
  from_node_id: String.t(),          # Source node ID
  to_node_id: String.t(),            # Target node ID
  edge_type: atom(),                 # :supports | :conflicts | :supersedes | :depends_on | :implements | :considers
  rationale: String.t() | nil,       # Explanation
  weight: float() | nil,             # Relationship strength (0.0-1.0)
  change_id: String.t(),             # Groups related changes
  inserted_at: DateTime.t(),         # Creation timestamp
  updated_at: DateTime.t()           # Last update timestamp
}

Build docs developers (and LLMs) love