- C-01: fix broken README link (call-graph-runtime.md → call-graph.md)
- C-02: add CallEdgeAttrs union type alias in schema.md
- C-03/W-12: rename TypedEdgeAttrs → OperationEdgeAttrs for consistent
{GraphType}EdgeAttrs naming pattern, update all references
- W-01: standardize terminology — prerequisites=structural/graph,
preconditions=reactive/computed, rename WorkflowNode.prerequisites
to preconditions, rename computePrerequisites to computePreconditions
- W-09: update ADR-001/002/003 status from Proposed to Accepted
- W-10: clarify call graph mutation API — addCall creates triggered
edges automatically, addDependency creates depends_on edges
- update review checklist with resolved items
3.0 KiB
3.0 KiB
ADR-002: Enforce DAG Invariants (No Cycles)
Status
Accepted
Context
Flowgraph represents two types of graphs: operation graphs (static type compatibility) and call graphs (dynamic call hierarchy). Both are directed acyclic graphs (DAGs) by nature:
- Operation graphs — type flow is acyclic. An operation's output feeding back into its own input is a design error.
- Call graphs — execution order is acyclic. A call being its own ancestor is physically impossible (you can't trigger yourself before you start).
- Workflow templates — rendered templates must be DAGs. Cycles in a template mean infinite loops in execution.
Taskgraph, the sibling package, allows cycles in its graph and detects them via hasCycles() and findCycles(). This makes sense because task dependencies can form cycles (e.g., iterative refinement where task A depends on task B which depends on task A's revised output).
Decision
Flowgraph enforces acyclicity at construction time. Adding an edge that would create a cycle throws CycleError. topologicalOrder() can always produce a valid ordering without needing a cycle check first.
This is a stricter invariant than taskgraph's approach. The rationale:
- Cycles in operation graphs are design errors — if operation A's output type is compatible with operation B's input, and B's output is compatible with A's input, that's circular type flow. It means infinite recursion is possible.
- Cycles in call graphs are physically impossible — a call cannot be its own ancestor. The call protocol ensures this via
parentRequestIdchains. - Cycles in templates are execution errors — a cycle in a
<Sequential>chain means infinite execution. This should be caught at template validation time, not at runtime. - DAG algorithms are simpler —
topologicalOrder()can always return a valid ordering. No need forhasCycles()+ fallback path.parallelGroups()always produces a valid grouping.reachableFrom()never loops.
Consequences
addEdge()validates before adding — if adding the edge would create a cycle, it throwsCycleErrorwith the cycle paths.fromSpecs()andfromCallEvents()cannot produce cyclic graphs — cycles in the input data throw errors.topologicalOrder()never throws — it can always produce a valid ordering because the graph is guaranteed acyclic.hasCycles()always returnsfalse— kept as a validation method for graphs loaded viafromJSON()(which doesn't enforce acyclicity during import).- This is different from taskgraph — consumers familiar with taskgraph's
hasCycles()→findCycles()→topologicalOrder()error-handling pattern need to adjust. In flowgraph, cycle prevention is at construction time, not query time.
References
- Taskgraph cycle handling:
@alkdev/taskgraph_ts/docs/architecture/graph-model.md - Operation graph: operation-graph.md
- Call graph: call-graph.md
- Error handling: error-handling.md