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
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:
- Programmer errors throw — invalid arguments, duplicate node IDs, cycles where acyclicity is enforced
- Operational conditions return structured results — validation errors, type mismatches, unreachable nodes
- Graph mutations throw on constraint violations — adding a duplicate node throws
DuplicateNodeError, adding a cycle-creating edge throwsCycleError
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
requestIdin acall.respondedevent → 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 withstatus: "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 data —
CycleErrorincludes cycle paths,InvalidTransitionErrorincludes from/to status,TypeIncompatErrorincludes 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