Decisions build on decisions. When an agent doubts something three hops downstream, tracing that doubt back to its actual premise means walking the chain, not guessing at it. That's RelationStore.TraceLineage now.
What it is
trace, err := relationStore.TraceLineage(ctx, "decision", "d42", 5)
// trace.Nodes = every item found walking depends_on/derives_from edges upstream
// trace.Truncated = true only if the walk genuinely had more past maxDepth
Each LineageNode carries the edge that reached it (RelationKind, EdgeClass, Confidence), whether that edge is currently invalidated, and whether the node itself has since been superseded. Three guards, non-negotiable: a visited-set prevents a cyclical graph from looping; hitting maxDepth sets an explicit Truncated flag rather than a silent cutoff; an invalidated edge is followed, not stopped at — flagged in the result, because the invalidated edge is usually where the actual answer lives.
The one genuinely open question, resolved
If the walk reaches a Decision that was itself superseded, does it stop there having noted the item is stale, or follow on to the replacement? TraceLineage follows it — on the same reasoning already accepted for invalidated edges: stopping would hide exactly what the query was asked to find. The replacement is recorded at the *same* depth as the item it replaced, not an extra hop out — a decision's revision history is metadata about its own identity, not a new premise in the reasoning chain.
Reuse, not reinvention — but not a straight copy either
The obvious precedent was Reachability (the general bounded graph-traversal primitive, shipped a few weeks earlier): same package, same RelationEdge type, already solving cycle detection with a visited-set BFS. Building trace_lineage from a different, more distant technique (a batched-fetch pattern from an unrelated composition-edge table) instead of that closer precedent would have missed real, already-solved work sitting one file away.
What Reachability doesn't have is a batched query per BFS depth level — it queries once per frontier node, fine at the widths seen so far, but not the shape to build new code on deliberately. trace_lineage adds that: one query per depth level, grouped by node type, instead of one query per node.
Two bugs a coverage pass actually caught
Worth naming, because both were wrong in ways that looked plausible until a test proved otherwise:
- The first
Truncatedimplementation assumed "frontier non-empty atmaxDepth"
meant "there's more." It doesn't — the nodes found at the boundary might simply have no further edges of their own. Fixed with a one-time peek past the boundary that doesn't record anything, just answers the question honestly.
- The first supersede-following implementation found a node's *immediate*
replacement and stopped — so a Decision revised twice would trace to the first revision, not the current one. Fixed by chasing the chain until it actually runs out, which then needed its own bound (a pathologically long revision history could otherwise run unbounded) — capped at the same MaxLineageDepth ceiling as the main walk.