Files
flowgraph/docs/architecture/error-handling.md
glm-5.1 907c33650f fix: architecture review - address 5 critical issues, 6 warnings, 3 suggestions
Critical fixes:
- C1: Create standalone ADR-006 file (edge type consistency),
  extract from open-questions.md inline content
- C2: Convert CallResult from plain interface to TypeBox schema,
  aligning with 'TypeBox as single source of truth' constraint
- C3: Add fromJSON() cycle detection specification - enforce
  ADR-002 DAG invariant even on deserialized input
- C4: Rewrite consumer-integration.md Phase 4 to use ADR-005
  event-append pattern instead of direct signal mutation
- C5: Fix operator precedence bug in consumer-integration.md
  (missing parentheses around OR condition)

Warnings addressed:
- W1: Fix immutability claim - operation graph is 'conventionally
  immutable', not prevented by API
- W2: Add EventLogProjection to reactive exports map
- W3: Add CallResult/CallResultSchema to schema exports map
- W4: Fix reactive-execution.md Level 1 error handling to use
  event-append pattern instead of direct signal mutation
- W5: Remove duplicate dataFlow inference description in schema.md
- W6: Clarify ADR-006 project context (flowgraph vs taskgraph)

Suggestions implemented:
- S1: Add 'reviewed' document lifecycle status between draft/stable,
  update all docs to reviewed status
- S2: Add carve-out note for analysis result types in schema.md
  constraints (they are ephemeral, not serialized)
- S3: Add isComplete() and getAggregateStatus() convenience methods
  to WorkflowReactiveRoot specification
2026-05-21 19:40:45 +00:00

11 KiB

status, last_updated
status last_updated
reviewed 2026-05-20

Error Handling

FlowgraphError hierarchy, validation error collection, and error boundaries.

Design Principle

Flowgraph follows taskgraph's error handling pattern:

  1. Programmer errors throw — invalid arguments, duplicate node IDs, cycles where acyclicity is enforced
  2. Operational conditions return structured results — validation errors, type mismatches, unreachable nodes
  3. Graph mutations throw on constraint violations — adding a duplicate node throws DuplicateNodeError, adding a cycle-creating edge throws CycleError

This means validation functions (validateGraph(), validateSchema(), validateTemplate()) never throw — they collect issues and return them. But construction functions (fromSpecs(), addNode(), addEdge()) do throw on constraint violations.

Error Hierarchy

FlowgraphError                    # Base class for all flowgraph errors
├── ConstructionError              # Errors during graph construction
│   ├── DuplicateNodeError         # Duplicate node key
│   ├── DuplicateEdgeError         # Duplicate edge key
│   ├── NodeNotFoundError          # Referenced node doesn't exist
│   ├── CycleError                 # Adding an edge would create a cycle
│   └── InvalidInputError          # Invalid input to fromJSON() or constructor
├── ValidationError                # Schema validation failed (single field)
├── GraphValidationError           # Graph-level validation issue
│   ├── CycleValidationError       # Cycle detected in the graph
│   └── DanglingReferenceError     # Edge references non-existent node
├── TypeIncompatError              # Type compatibility check failed
└── InvalidTransitionError         # Invalid call status transition

FlowgraphError

class FlowgraphError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "FlowgraphError";
  }
}

Base class. All flowgraph errors inherit from this.

ConstructionError

class ConstructionError extends FlowgraphError {
  constructor(message: string) {
    super(message);
    this.name = "ConstructionError";
  }
}

Base class for errors that occur during graph construction (fromSpecs(), addNode(), addEdge(), etc.).

DuplicateNodeError

class DuplicateNodeError extends ConstructionError {
  constructor(public readonly key: string) {
    super(`Node with key "${key}" already exists`);
    this.name = "DuplicateNodeError";
  }
}

Thrown when adding a node with a key that already exists in the graph.

DuplicateEdgeError

class DuplicateEdgeError extends ConstructionError {
  constructor(
    public readonly source: string,
    public readonly target: string,
  ) {
    super(`Edge "${source} -> ${target}" already exists`);
    this.name = "DuplicateEdgeError";
  }
}

Thrown when adding an edge between two nodes that already have an edge between them.

NodeNotFoundError

class NodeNotFoundError extends ConstructionError {
  constructor(public readonly key: string) {
    super(`Node "${key}" not found in graph`);
    this.name = "NodeNotFoundError";
  }
}

Thrown when referencing a node that doesn't exist (e.g., adding an edge with a non-existent endpoint).

CycleError

class CycleError extends ConstructionError {
  constructor(public readonly cycles: string[][]) {
    super(`Adding this edge would create a cycle: ${JSON.stringify(cycles)}`);
    this.name = "CycleError";
  }
}

Thrown when adding an edge would create a cycle. The cycles field contains the cycle paths that would be created.

Note: CycleError is flowgraph's cycle error, thrown by addEdge() during construction. Taskgraph uses a different error name (CircularDependencyError, thrown by topologicalOrder()). The two are distinct errors for distinct contexts — flowgraph prevents cycles at construction time, taskgraph allows cycles and detects them later.

InvalidInputError

class InvalidInputError extends ConstructionError {
  constructor(public readonly errors: ValidationError[]) {
    super(`Invalid input: ${errors.length} validation error(s)`);
    this.name = "InvalidInputError";
  }
}

Thrown by fromJSON() when the input data fails schema validation. The errors field contains the array of ValidationError objects describing which fields failed validation. This is distinct from ValidationError (which is a returned result) — InvalidInputError is thrown because fromJSON() enforces that deserialized data is structurally valid.

ValidationError

interface ValidationError {
  type: "schema";
  nodeKey: string;
  field: string;
  message: string;
  value?: unknown;
}

Returned by validateSchema() when a node's attributes don't match the TypeBox schema. This is a structured result, not a thrown error.

GraphValidationError

interface GraphValidationError {
  type: "graph";
  category: "cycle" | "dangling-reference" | "orphan-node" | "status-inconsistency";
  details: unknown;
}

Returned by validateGraph() for graph-level issues:

Category Meaning Details
cycle The graph contains cycles { cycles: string[][] }
dangling-reference An edge references a non-existent node { source: string, target: string }
orphan-node A node has no incoming or outgoing edges { nodeKey: string }
status-inconsistency A call node has incompatible status with its parent (e.g., parent completed but child still running) { nodeKey: string, parentKey: string, nodeStatus: string, parentStatus: string }

InvalidTransitionError

class InvalidTransitionError extends FlowgraphError {
  constructor(
    public readonly requestId: string,
    public readonly from: CallStatus,
    public readonly to: CallStatus,
  ) {
    super(`Invalid status transition for call ${requestId}: ${from}${to}`);
    this.name = "InvalidTransitionError";
  }
}

Thrown when updateNodeStatus() is called with an invalid transition (e.g., completed → running).

TypeIncompatError

interface TypeIncompatError {
  type: "type-compat";
  sourceKey: string;
  targetKey: string;
  compatible: false;
  mismatches: TypeMismatch[];
}

interface TypeMismatch {
  path: string;
  expected: string;
  actual: string;
}

Returned by validateTemplate() and analyzeTypeCompat() when an edge between operations has incompatible type schemas. This is a structured result, not a thrown error.

Error Collection

Validation functions collect all issues into an array and return them. They do not throw on the first error:

const errors = graph.validate();
// errors is AnyValidationError[], which may be empty

for (const error of errors) {
  if (error.type === "schema") {
    console.log(`Node ${error.nodeKey} has invalid field ${error.field}: ${error.message}`);
  } else if (error.type === "graph" && error.category === "cycle") {
    console.log(`Graph has cycles: ${error.details.cycles}`);
  }
}

This "collect all errors" pattern allows consumers to see all issues at once, rather than fixing them one at a time.

AnyValidationError

type AnyValidationError = ValidationError | GraphValidationError | TypeIncompatError;

Union type for all validation errors. Consumers use the type discriminator to handle each category:

switch (error.type) {
  case "schema":      // ValidationError
  case "graph":       // GraphValidationError
  case "type-compat": // TypeIncompatError
}

Throwing vs. Returning

The distinction between thrown errors and returned errors:

Function Behavior Rationale
addNode(key, attrs) Throws DuplicateNodeError on duplicate key Adding a duplicate is a programmer error
addEdge(source, target) Throws NodeNotFoundError on missing endpoint Edge without endpoints is invalid
addEdge(source, target) Throws CycleError if edge creates cycle DAG invariant must be maintained
fromJSON(data) Throws InvalidInputError on validation failure Deserialized data must be structurally valid
updateNodeStatus(id, status) Throws InvalidTransitionError on invalid transition State machine must be enforced
validateSchema() Returns ValidationError[] Schema issues are validations, not crashes
validateGraph() Returns GraphValidationError[] Graph issues are validations, not crashes
validateTemplate() Returns AnyValidationError[] Template issues are validations, not crashes
analyzeTypeCompat() Returns TypeCompatResult (includes mismatches) Type incompatibility is advisory, not blocking
topologicalOrder() Throws CycleError on cycles No valid ordering exists from a cyclic graph

This matches taskgraph's pattern: construction enforces invariants (throwing on violations), validation reports issues (returning error arrays).

Error Boundaries in the Call Graph

The call graph has an additional error boundary: updateFromEvent(). Call events arrive from the pub/sub layer and may reference unknown operations or have invalid transitions. The error boundary handles these gracefully:

  • Unknown requestId in a call.responded event → log warning, ignore the event (the call may have been created by a different process)
  • Invalid status transition → log warning, ignore the event (the call may have transitioned in a different order)
  • Unknown operationId → create the node anyway with status: "pending" (the operation may be registered later)

This makes updateFromEvent() resilient to out-of-order, duplicate, and partial events. Errors are logged but don't crash the process.

Constraints

  • Validation functions never throw — they collect errors and return them. This is a contract.
  • Construction functions throw on invariant violations — adding a cycle-creating edge is a programming error, not a validation finding.
  • All errors have structured dataCycleError includes cycle paths, InvalidTransitionError includes from/to status, TypeIncompatError includes mismatch details.
  • Error messages are descriptive — errors include enough context to diagnose the problem without additional lookups.
  • Error classes follow the taskgraph pattern — naming, structure, and behavior match @alkdev/taskgraph_ts/src/error/.

References

  • Taskgraph errors: @alkdev/taskgraph_ts/src/error/
  • Call protocol events: @alkdev/operations/src/call.ts
  • Schema: schema.md